Skip to main content

supabase_flutter 3.0.0-dev.14 copy "supabase_flutter: ^3.0.0-dev.14" to clipboard
supabase_flutter: ^3.0.0-dev.14 copied to clipboard

Flutter integration for Supabase. This package makes it simple for developers to build secure and scalable products.


Supabase Logo

supabase_flutter

Flutter client library for Supabase.

Guides · Reference Docs · Migration Guides

pub package pub test

supabase_flutter is the Supabase client for Flutter apps: typed database queries, auth, realtime, storage and edge functions, plus the Flutter parts around them, such as session persistence, deep links for auth redirects and the app lifecycle. For Dart code that does not run in Flutter, such as a server or a command line tool, use supabase instead.

This README describes v3. Most Supabase Dart examples published elsewhere are v2 code, which no longer compiles. To upgrade a v2 project, follow MIGRATION.md.

Using an AI coding agent #

supabase_flutter ships package skills, instructions that teach coding agents such as Claude Code, Cursor, Gemini or Copilot the current API. Agents trained on v2 examples otherwise tend to write code that does not compile against v3. The skills for generating the schema and for testing ship with the dev dependencies they are about. Install the skills of every package you use:

dart run skills@ get -p supabase_flutter
dart run skills@ get -p supabase_typegen
dart run skills@ get -p supabase_test
Skill Package Covers
supabase-flutter-idioms supabase_flutter Writing or reviewing any code that uses the client
supabase-flutter-realtime supabase_flutter Live lists with stream(), onTableChanges, broadcast and presence
supabase-flutter-auth-setup supabase_flutter OAuth, magic links and password reset that return to the app with a link
supabase-flutter-migrate-v3 supabase_flutter Upgrading a project from v2 to v3
supabase-typegen-generate supabase_typegen Generating the typed database schema and keeping it in sync
supabase-test-testing supabase_test Unit and widget tests with supabase_test

Run the commands again after upgrading the packages to update the skills. Commit the installed skills together with .config/dart_skills/ so everyone working on the project gets the same version.

Getting started #

1. Add the package #

flutter pub add supabase_flutter

2. Initialize Supabase #

Call Supabase.initialize once, before runApp. It restores the persisted session before it returns. Find the URL and the publishable key of your project in the dashboard under Project Settings > API Keys.

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Supabase.initialize(
    url: 'https://your-project.supabase.co',
    publishableKey: 'your-publishable-key',
  );
  runApp(const MaterialApp(home: Placeholder()));
}

final supabase = Supabase.instance.client;

Use this one client everywhere in the app.

3. Generate the typed schema #

Queries are typed by a Dart file generated from your database schema. It holds a row type per table, BooksRow for a books table, the BooksInsert and BooksUpdate values that writes accept, and a Books class with the table definition and one constant per column. Generate it with the Supabase CLI, version 2.122.0 or newer, from the directory of your pubspec.yaml:

flutter pub add dev:supabase_typegen
supabase gen types --lang dart --local > lib/supabase_schema.g.dart

--local reads the schema of the stack started with supabase start. Use --linked for the project linked with supabase link, or --project-id <ref> for any hosted project. Never edit the generated file: regenerate it after every database migration and commit it. The supabase_typegen README lists everything it generates.

4. Query #

Import the generated file next to supabase_flutter, and pass a table definition to table():

final List<BooksRow> books = await supabase
    .table(Books.table)
    .select()
    .where(Books.inPrint.isTrue())
    .order(Books.title);

print(books.first.title);

Rules of the v3 API #

  • Query tables with table() and the generated schema. from() with Map<String, dynamic> rows is only the fallback for a table that is not in the generated file.
  • Pass publishableKey to Supabase.initialize. anonKey does not exist.
  • Realtime events arrive on streams. No method takes a callback:.
  • A typed update() or delete() sends nothing until .where(…) or .all() is called on it.
  • Catch the SupabaseException hierarchy, never ClientException, TimeoutException or String.
  • Create a stream() once, in a State or a controller, never inside build().

Contents #

The examples below use a books table with a foreign key to an authors table, and the types generated for them.

Database #

Filters are methods on the column constants, combined with &, | and not(). The compiler checks that a value matches the type of its column, and isNull() only exists on nullable columns.

// A list of rows.
final List<BooksRow> cheapBooks = await supabase
    .table(Books.table)
    .select()
    .where(Books.inPrint.isTrue() & Books.price.lt(10))
    .order(Books.createdAt.desc())
    .limit(20);

// Exactly one row, or a PostgrestException when there is none.
final BooksRow book = await supabase
    .table(Books.table)
    .select()
    .where(Books.id.eq(1))
    .single();

// Some columns only, with the author embedded through the foreign key.
final titles = await supabase.table(Books.table).selectOnly([
  Books.title,
  Books.authors(Authors.name),
]);
final String title = titles.first.read(Books.title);
final String? author = titles.first.read(Books.authors)?.read(Authors.name);

Writes take the generated insert and update types, which mark the required columns as required parameters:

await supabase
    .table(Books.table)
    .insert(BooksInsert(authorId: 2, title: 'Dune'));

// Add select() to get the written row back.
final BooksRow inserted = await supabase
    .table(Books.table)
    .insert(BooksInsert(authorId: 2, title: 'Emma'))
    .select()
    .single();

await supabase
    .table(Books.table)
    .update(BooksUpdate(inPrint: false).setPriceToNull())
    .where(Books.id.eq(1));

await supabase.table(Books.table).delete().where(Books.id.eq(1));

A null argument to BooksInsert or BooksUpdate leaves the column out, and the generated set…ToNull() methods write SQL NULL. Several rows go through insertAll() and upsertAll().

Database functions get a typed method on rpc:

final BooksRow? book = await supabase.rpc.bookById(bookId: 1);

The untyped supabase.from('books') and supabase.rpc('fn', params: {…}) remain for tables and functions that are not in the generated file. The full list of filters and modifiers is in the Dart reference.

Authentication #

Sign-in methods that always produce a session return the Session. signUp() and verifyOTP() return an AuthResponse, since they can complete without one, for example while the email address waits for confirmation.

await supabase.auth.signUp(
  email: 'user@example.com',
  password: 'a-strong-password',
);

final Session session = await supabase.auth.signInWithPassword(
  email: 'user@example.com',
  password: 'a-strong-password',
);

// Sends a magic link. The link opens the app through a deep link.
await supabase.auth.signInWithOtp(email: 'user@example.com');

await supabase.auth.signOut();

onAuthStateChange emits a sealed AuthState, with one subtype per event. Every new listener first receives AuthInitialSession with the current session, or null when no user is signed in. A token refresh that fails, for example while the device is offline, arrives as an error on the stream, so always pass onError. Without it the error is unhandled:

final subscription = supabase.auth.onAuthStateChange.listen(
  (state) {
    switch (state) {
      case AuthInitialSession(:final session):
        print('Started with ${session?.user.email ?? 'no user'}');
      case AuthSignedIn(:final session):
        print('Signed in as ${session.user.email}');
      case AuthSignedOut(:final reason):
        print('Signed out, reason: ${reason?.name}');
      case _:
        print('Auth event: ${state.event.name}');
    }
  },
  onError: (Object error) => print('Auth error: $error'),
);

supabase.auth.currentUser and supabase.auth.currentSession hold the state at any time, without listening.

Native Apple sign in #

Use the sign_in_with_apple package and follow its README to set up Apple sign in on iOS and macOS. Then add the bundle ID of your app in the Supabase dashboard under Authentication > Providers > Apple.

import 'dart:convert';

import 'package:crypto/crypto.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

final supabase = Supabase.instance.client;

Future<Session> signInWithApple() async {
  final rawNonce = supabase.auth.generateRawNonce();
  final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();

  final credential = await SignInWithApple.getAppleIDCredential(
    scopes: [
      AppleIDAuthorizationScopes.email,
      AppleIDAuthorizationScopes.fullName,
    ],
    nonce: hashedNonce,
  );

  final idToken = credential.identityToken;
  if (idToken == null) {
    throw const AuthException('Apple returned no ID token.');
  }

  return supabase.auth.signInWithIdToken(
    provider: OAuthProvider.apple,
    idToken: idToken,
    nonce: rawNonce,
  );
}

Native Google sign in #

Use the google_sign_in package and follow its README for the platform setup. Google sign in with Supabase needs a web client ID, plus an iOS client ID on iOS:

Add the web client ID in the Supabase dashboard under Authentication > Providers > Google, and turn on Skip nonce check, which iOS needs.

import 'package:google_sign_in/google_sign_in.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

final supabase = Supabase.instance.client;

const webClientId = 'my-web.apps.googleusercontent.com';
const iosClientId = 'my-ios.apps.googleusercontent.com';

/// Call once, when the app starts.
Future<void> initializeGoogleSignIn() {
  return GoogleSignIn.instance.initialize(
    clientId: iosClientId,
    serverClientId: webClientId,
  );
}

Future<Session> signInWithGoogle() async {
  final account = await GoogleSignIn.instance.authenticate();
  final idToken = account.authentication.idToken;
  if (idToken == null) {
    throw const AuthException('Google returned no ID token.');
  }
  final authorization = await account.authorizationClient
      .authorizationForScopes(['email']);

  return supabase.auth.signInWithIdToken(
    provider: OAuthProvider.google,
    idToken: idToken,
    accessToken: authorization?.accessToken,
  );
}

Native Facebook sign in #

Use flutter_facebook_auth ^7.1.7 with signInWithIdToken on iOS and Android:

  • iOS uses Facebook's Limited Login (LoginTracking.limited), which returns a LimitedToken holding an OIDC ID token.
  • Android needs LoginBehavior.webOnly and the openid permission to receive an OIDC ID token in ClassicToken.authenticationToken.
import 'dart:io';

import 'package:flutter_facebook_auth/flutter_facebook_auth.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

final supabase = Supabase.instance.client;

Future<Session> signInWithFacebook() async {
  final LoginResult result;
  if (Platform.isIOS) {
    result = await FacebookAuth.instance.login(
      permissions: ['public_profile', 'email'],
      loginTracking: LoginTracking.limited,
    );
  } else {
    result = await FacebookAuth.instance.login(
      permissions: ['public_profile', 'email', 'openid'],
      loginBehavior: LoginBehavior.webOnly,
    );
  }
  if (result.status != LoginStatus.success) {
    throw AuthException('Facebook sign in failed: ${result.message}');
  }

  final idToken = switch (result.accessToken) {
    LimitedToken(:final tokenString) => tokenString,
    ClassicToken(:final authenticationToken) => authenticationToken,
    _ => null,
  };
  if (idToken == null) {
    throw const AuthException(
      'Facebook returned no ID token. On Android, use LoginBehavior.webOnly '
      'and request the openid permission.',
    );
  }

  return supabase.auth.signInWithIdToken(
    provider: OAuthProvider.facebook,
    idToken: idToken,
  );
}

Without the Facebook SDK, use signInWithOAuth() from the next section, which runs the Facebook login in the browser.

OAuth login #

signInWithIdToken() supports Apple, Google, Facebook, Kakao and Keycloak. Every other provider signs in through signInWithOAuth(), which opens the browser. Pass a deep link as redirectTo to bring the user back to the app, as described in Deep links. signInWithOAuth() returns once the browser is open, and false when it could not be opened:

import 'package:flutter/foundation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

final supabase = Supabase.instance.client;

Future<void> signInWithGitHub() async {
  final launched = await supabase.auth.signInWithOAuth(
    OAuthProvider.github,
    // On the web the browser returns to the current page instead.
    redirectTo: kIsWeb ? null : 'io.supabase.flutterdemo://login-callback',
  );
  if (!launched) {
    throw const AuthException('Could not open the sign in page.');
  }
}

The sign in completes when the app receives the link, and onAuthStateChange then emits AuthSignedIn. Listen for it rather than awaiting it after the call: the user can close the browser without signing in, and no event arrives then.

To run the flow in an in-app browser session instead of the system browser, add supabase_flutter_web_auth.

Passkeys #

Passkeys are a beta feature. Enable them in the Supabase dashboard under Authentication > Configuration > Passkeys before using these methods.

supabase_flutter performs the server side of the WebAuthn ceremony and hands the platform prompt (Face ID, Touch ID, a security key) to an authenticator you pass in. It does not depend on a passkey plugin itself, so apps that do not use passkeys keep their minimum platform versions.

The PasskeyAuthenticator of the passkeys plugin implements the PasskeyAuthenticatorInterface these methods expect (since passkeys 2.21.0), and any other implementation works too:

import 'package:passkeys/authenticator.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

final supabase = Supabase.instance.client;
final authenticator = PasskeyAuthenticator();

Future<void> passkeys() async {
  // Prompts the user to pick and unlock a passkey.
  await supabase.auth.signInWithPasskey(authenticator);

  // Registers a new passkey for the signed in user.
  await supabase.auth.registerPasskey(authenticator);

  // Manages the passkeys of the signed in user.
  final passkeys = await supabase.auth.passkey.list();
  await supabase.auth.passkey.update(
    passkeyId: passkeys.first.id,
    friendlyName: 'My phone',
  );
  await supabase.auth.passkey.delete(passkeyId: passkeys.first.id);
}

The plugin documents its platform requirements and setup, and how to handle a failed ceremony, such as the user cancelling.

Android Restore Credentials #

Android's Restore Credentials restore keys are passkeys, so they use the same beta passkey feature and the same authenticator. The PasskeyAuthenticator of the passkeys plugin implements the RestoreCredentialInterface these methods expect (since passkeys 2.23.1).

  • Call supabase.auth.createRestoreKey(authenticator) after a sign in that is not anonymous.
  • Call supabase.auth.signInWithRestoreKey(authenticator) on the first launch on a new device.
  • On sign out, delete the passkey on the server and call clearRestoreCredential() on the authenticator.

The API documentation of AuthClientRestoreCredential has the details.

Realtime #

Postgres changes only arrive for tables in the supabase_realtime publication. Add a table to it in the dashboard under Database > Publications, or in a migration:

alter publication supabase_realtime add table books;

Live rows with stream() #

stream() emits the rows of a table, first as they are and then again after every change:

final Stream<List<BooksRow>> books = supabase
    .table(Books.table)
    .stream(primaryKey: [Books.id])
    .filter(Books.authorId.eq(2))
    .order(Books.title);

Create the stream once, in a State or a controller. A stream created inside build() subscribes again on every rebuild, which loses the realtime connection.

import 'package:flutter/material.dart';
import 'package:my_app/supabase_schema.g.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

class BookList extends StatefulWidget {
  const BookList({super.key});

  @override
  State<BookList> createState() => _BookListState();
}

class _BookListState extends State<BookList> {
  final _books = Supabase.instance.client
      .table(Books.table)
      .stream(primaryKey: [Books.id])
      .order(Books.title);

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<BooksRow>>(
      stream: _books,
      builder: (context, snapshot) {
        if (snapshot.hasError) return Text('${snapshot.error}');
        final books = snapshot.data ?? const [];
        return ListView(children: [for (final book in books) Text(book.title)]);
      },
    );
  }
}

Postgres changes #

onTableChanges emits each insert, update and delete as a typed change. Create the stream before calling subscribe(), and remove the channel when you no longer need it:

final channel = supabase.channel('books');
final subscription = channel
    .onTableChanges(Books.table, filters: [Books.authorId.eq(2)])
    .listen((change) {
      switch (change) {
        case TypedPostgresInsert(:final newRow):
          print('Added ${newRow.title}');
        case TypedPostgresUpdate(:final newRow):
          print('Changed ${newRow.title}');
        case TypedPostgresDelete(:final oldRow):
          print('Removed ${oldRow.read(Books.id)}');
      }
    });
channel.subscribe();

// Later, when done:
await subscription.cancel();
await supabase.removeChannel(channel);

Pass event: PostgresChangeEvent.insert to receive one kind of change only.

Broadcast #

Broadcast sends low latency messages between connected clients, without going through the database. Send once the channel is subscribed:

final channel = supabase.channel('room');

channel.onBroadcast(event: 'cursor').listen((message) {
  print('Cursor moved: ${message['payload']}');
});

channel.onStatusChange.listen((change) async {
  if (change.status == RealtimeSubscribeStatus.subscribed) {
    await channel.sendBroadcastMessage(
      event: 'cursor',
      payload: {'x': 30, 'y': 50},
    );
  }
});

channel.subscribe();

Presence #

Presence shares state between the clients of a channel, such as who is online:

final channel = supabase.channel('room');

channel.onPresenceSync.listen((_) {
  print('Online: ${channel.presenceState()}');
});
channel.onPresenceJoin.listen((payload) {
  print('Joined: ${payload.newPresences}');
});
channel.onPresenceLeave.listen((payload) {
  print('Left: ${payload.leftPresences}');
});

channel.onStatusChange.listen((change) async {
  if (change.status == RealtimeSubscribeStatus.subscribed) {
    await channel.track({'online_at': DateTime.now().toIso8601String()});
  }
});

channel.subscribe();

Realtime and the app lifecycle #

Supabase disconnects the realtime socket when the app is paused, and reconnects it, rejoining every joined channel, when the app is resumed. Every reconnect costs a new socket and a new authorization of each channel.

On iOS, a full-screen view such as a video ad or a system sheet reports the app as paused while it is on screen. Pass RealtimeLifecycleOptions to keep the socket open through pauses shorter than a grace period:

await Supabase.initialize(
  url: 'https://your-project.supabase.co',
  publishableKey: 'your-publishable-key',
  realtimeLifecycleOptions: const RealtimeLifecycleOptions(
    disconnectAfterPause: Duration(minutes: 1),
  ),
);

RealtimeLifecycleOptions.manual() keeps the socket out of the app lifecycle entirely. The socket then stays open in the background until the operating system closes it, and the realtime client reconnects and rejoins its channels once the app runs again.

Storage #

storage.from() selects a bucket. It is the storage API, not a database query.

import 'dart:convert';
import 'dart:typed_data';

import 'package:supabase_flutter/supabase_flutter.dart';

final supabase = Supabase.instance.client;

Future<void> storage() async {
  final bucket = supabase.storage.from('avatars');

  await bucket.uploadBinary('users/1/notes.txt', utf8.encode('File content'));

  final Uint8List downloaded = await bucket.download('users/1/notes.txt');

  final String url = await bucket.createSignedUrl('users/1/notes.txt', 60);
}

upload() takes a File instead of bytes, on every platform except the web.

Edge Functions #

final response = await supabase.functions.invoke(
  'hello',
  body: {'name': 'Dart'},
);
print(response.data);

Errors #

Every exception the SDK throws for a failed request extends SupabaseException. SupabaseApiException means the server answered with an error, and SupabaseTransportException means no response arrived, including a timeout:

try {
  await supabase.table(Books.table).select();
} on SupabaseApiException catch (error) {
  print('${error.statusCode} ${error.errorCode}: ${error.message}');
} on SupabaseTransportException catch (error) {
  print('No response: ${error.cause}');
}

Each service also has its own base class, such as PostgrestException, AuthException, StorageException or FunctionsException, to catch the errors of one service only.

A deep link brings the user back into your app from an email or a browser. Set them up when you use any of these:

  • Magic link sign in.
  • Email sign in with Confirm email enabled.
  • Password reset by email.
  • signInWithOAuth().

supabase_flutter handles deep links on Android, iOS, the web, macOS, Windows and Linux. It completes the sign in itself when the app is opened through a link carrying a code (the PKCE flow, the default) or an access_token.

By default, every link with a code, access_token, error, error_code or error_description parameter, in the query or the fragment, is treated as an auth callback. If your app receives other links with one of those parameters, pass detectSessionInUriPredicate to tell the auth callbacks apart, for example by the redirect URL they arrive on. On the web the predicate receives the URL of the page, so check only the parameters there:

import 'package:flutter/foundation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

const _authParameters = {
  'access_token',
  'code',
  'error',
  'error_code',
  'error_description',
};

bool _isAuthCallback(Uri uri) {
  if (!kIsWeb && uri.host != 'login-callback') return false;
  final fragmentParameters = Uri.splitQueryString(uri.fragment);
  return _authParameters.any(
    (parameter) =>
        uri.queryParameters.containsKey(parameter) ||
        fragmentParameters.containsKey(parameter),
  );
}

Future<void> initializeSupabase() async {
  await Supabase.initialize(
    url: 'https://your-project.supabase.co',
    publishableKey: 'your-publishable-key',
    authOptions: FlutterAuthClientOptions(
      detectSessionInUriPredicate: _isAuthCallback,
    ),
  );
}

detectSessionInUri: false turns the handling off, for example when the app calls exchangeCodeForSession itself.

Dashboard configuration #

Add your redirect URL in the dashboard under Authentication > URL Configuration > Redirect URLs.

The redirect URL has the format [YOUR_SCHEME]://[YOUR_HOSTNAME], for example io.supabase.flutterdemo://login-callback. Choose any scheme and host name, as long as the scheme is unique on the user's device, which is why the reverse domain of your website is the usual choice.

authentication settings page

Flutter configuration #

supabase_flutter uses app_links to receive deep links. Follow its getting started guide for the configuration of each platform. The native mobile deep linking guide covers the Supabase side for each OAuth provider.

Custom session storage #

By default, supabase_flutter persists the user session, and the code verifiers of pending PKCE flows, with the SharedPreferencesAsync API of shared_preferences. If your own code still uses the legacy SharedPreferences API, migrate it to SharedPreferencesAsync: on Windows and Linux both APIs rewrite the same file from their own cache, so a write through one drops what the other wrote, and a mixed setup can lose your preferences as well as the session.

To store them anywhere else, pass an AuthAsyncStorage implementation. This one keeps the session in secure storage with flutter_secure_storage:

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

class SecureAuthStorage extends AuthAsyncStorage {
  final _storage = const FlutterSecureStorage();

  @override
  Future<String?> getItem(String key) => _storage.read(key: key);

  @override
  Future<void> setItem(String key, String value) =>
      _storage.write(key: key, value: value);

  @override
  Future<void> removeItem(String key) => _storage.delete(key: key);
}

Future<void> initialize() async {
  await Supabase.initialize(
    url: 'https://your-project.supabase.co',
    publishableKey: 'your-publishable-key',
    authOptions: FlutterAuthClientOptions(asyncStorage: SecureAuthStorage()),
  );
}

The session is stored under a key derived from your project URL, which defaultPersistSessionKey returns and storageKey on the auth options overrides. The code verifiers are stored under keys prefixed with it.

Set persistSession to false to keep the session in memory only. The code verifiers are still stored, so a sign in through an email link or an OAuth redirect can complete after the app was closed in between.

await Supabase.initialize(
  url: 'https://your-project.supabase.co',
  publishableKey: 'your-publishable-key',
  authOptions: const FlutterAuthClientOptions(persistSession: false),
);

Logging #

All Supabase packages log through the logging package, with loggers under the shared supabase hierarchy. They never print anything and never change any package:logging settings, so your app decides whether, where and at which level logs are handled.

Listen to Supabase logs #

The simplest setup listens on the root logger and filters on the logger name. It receives the records allowed by Logger.root.level, which defaults to Level.INFO. Set Logger.root.level = Level.ALL to also receive the fine-grained records, including the Level.FINEST payloads described below.

import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';

void main() {
  Logger.root.onRecord.listen((record) {
    if (record.loggerName.startsWith('supabase.')) {
      debugPrint(
        '${record.loggerName}: ${record.level.name}: '
        '${record.message} ${record.error ?? ''}',
      );
    }
  });
}

Filter Supabase logs by level #

To set the level of the Supabase logs apart from the rest of your app, enable hierarchical logging and configure the supabase logger:

import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';

void main() {
  hierarchicalLoggingEnabled = true;
  Logger('supabase')
    ..level = Level.INFO
    ..onRecord.listen((record) {
      debugPrint(
        '${record.loggerName}: ${record.level.name}: '
        '${record.message} ${record.error ?? ''}',
      );
    });
}

Without hierarchicalLoggingEnabled = true, package:logging only supports listening and setting levels on Logger.root.

Log levels #

  • Level.CONFIG: client configuration during initialization.
  • Level.FINEST: wire-level details such as request and response payloads. These records can contain sensitive data like row data, so handle them accordingly. Credentials never appear in any record at any level: headers, URL parameters and payload fields that carry an API key, access token or other credential are replaced by <redacted> before they are logged.
  • Level.FINE: internal lifecycle events, for example the realtime socket connecting or disconnecting.
  • Level.INFO: notable events, for example completed initialization or session recovery.
  • Level.WARNING: recoverable problems, for example a failed retry attempt or an unexpected disconnect.

Package loggers #

Package Logger
supabase_flutter Logger('supabase.flutter')
supabase Logger('supabase.dart')
postgrest Logger('supabase.postgrest')
supabase_auth Logger('supabase.auth')
supabase_realtime Logger('supabase.realtime')
supabase_storage Logger('supabase.storage')
supabase_functions Logger('supabase.functions')
iceberg Logger('supabase.storage.iceberg')

Request ids and tracing #

When a request fails, the Supabase logs of your project usually say why. There are two ways to find the entries of a specific request: the request id on the exception, which needs no setup, and trace propagation, which ties requests into the traces of a tracing tool you already use.

Request id on a failure #

Every response from the Supabase gateway carries a request id, and the exceptions thrown by the auth, database, storage and functions clients expose it as requestId. Paste it into the log explorer of your project dashboard to find the server side logs of the failed request, or quote it when you contact support.

try {
  await supabase.table(Books.table).select();
} on SupabaseException catch (error) {
  print('Request failed: ${error.message} (request id: ${error.requestId})');
}

requestId is also part of the toString() of every one of these exceptions, so an error reporter that records the exception records the id with it.

It is read from the sb-request-id response header, falling back to x-request-id for a self-hosted stack behind a reverse proxy that assigns its own. It is null when no response was received, for example on a network failure, or when the response carried neither header. Realtime errors do not carry a request id.

Trace propagation #

If your app already records traces with a tool like Sentry or OpenTelemetry, the client can send the active W3C trace context along with each request, so the Supabase logs of that request carry the trace id of the span that made it.

Propagation is off by default. Turn it on by passing TracePropagationOptions with a traceContextProvider, which the client calls before each request to read the active trace. Return null when there is no active trace, and the request is sent without trace headers.

Sentry

toSentryTrace() returns a header in the sentry-trace format, so convert it with formatAsW3CHeader from package:sentry before passing it on:

import 'package:sentry/sentry.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

Future<void> initialize() async {
  await Supabase.initialize(
    url: 'https://your-project.supabase.co',
    publishableKey: 'your-publishable-key',
    tracePropagationOptions: TracePropagationOptions(
      traceContextProvider: () {
        final span = Sentry.getSpan();
        if (span == null) return null;
        return TraceContext(
          traceparent: formatAsW3CHeader(span.toSentryTrace()),
          baggage: span.toBaggageHeader()?.value,
        );
      },
    ),
  );
}

OpenTelemetry

Let the propagator you registered write the active context into a map, and read the headers back out of it with TraceContext.fromCarrier:

import 'package:opentelemetry/api.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

class MapSetter implements TextMapSetter<Map<String, String>> {
  @override
  void set(Map<String, String> carrier, String key, String value) {
    carrier[key] = value;
  }
}

Future<void> initialize() async {
  await Supabase.initialize(
    url: 'https://your-project.supabase.co',
    publishableKey: 'your-publishable-key',
    tracePropagationOptions: TracePropagationOptions(
      traceContextProvider: () {
        final carrier = <String, String>{};
        globalTextMapPropagator.inject(Context.current, carrier, MapSetter());
        return TraceContext.fromCarrier(carrier);
      },
    ),
  );
}

Other tracing tools

For a tool that exposes the ids of the active span rather than a W3C header, build the context with TraceContext.w3c:

traceContextProvider: () => TraceContext.w3c(
  traceId: span.traceId,
  spanId: span.spanId,
  sampled: span.isSampled,
),

A traceparent that is not valid W3C trace context is not sent, and the client logs a warning the first time it sees one.

Which requests carry trace headers

The headers are only added to requests addressed to a Supabase host: your project URL, *.supabase.co, *.supabase.in, and localhost, 127.0.0.1 and ::1 for local development. Third-party hosts never receive them: when a Supabase host redirects a request to another host, the redirected request is sent without them. The exception is the web, where the browser follows redirects itself and a redirect to another host still carries the headers. They are sent on every HTTP request the client makes, including realtime broadcasts sent over HTTP, but not over the realtime WebSocket connection.

Sampling

On a trace that is not sampled, one whose traceparent carries a sampled flag of 0, the client still sends traceparent so the Supabase logs get a trace id to correlate on, but withholds tracestate and baggage. Set respectSamplingDecision to false to send them on unsampled traces as well.

Testing #

Test code that uses Supabase with supabase_test: a real client whose HTTP requests are answered by stubs and whose realtime socket runs in memory, so no running stack is needed. In widget tests, initialize Supabase with initializeTestSupabase from package:supabase_flutter/testing.dart. The supabase-test-testing skill of supabase_test teaches a coding agent the same setup.

Migrating #

The breaking changes of each major version, and what to do about them, are documented in MIGRATION.md.

Contributing #

  • Fork the repo on GitHub
  • Clone the project to your own machine
  • Commit changes to your own branch
  • Push your work back up to your fork
  • Submit a pull request so that we can review your changes and merge

License #

This repo is licensed under MIT.

Resources #

983
likes
160
points
1.35M
downloads

Documentation

Documentation

Publisher

verified publishersupabase.io

Weekly Downloads

Flutter integration for Supabase. This package makes it simple for developers to build secure and scalable products.

Homepage
Repository (GitHub)
View/report issues

Topics

#supabase #flutter #backend #authentication #database

License

MIT (license)

Dependencies

app_links, clock, flutter, http, logging, meta, passkeys_platform_interface, shared_preferences, supabase, supabase_common, url_launcher, web

More

Packages that depend on supabase_flutter