supabase-flutter-idioms
$
npx mdskill add supabase/supabase-flutter/supabase-flutter-idiomsMost published Supabase Dart examples are v2 code. v3 changed many of the APIs those examples use, so do not copy a pattern from memory without checking it against this file. To upgrade an existing v2 project, use the `supabase-flutter-migrate-v3` skill instead.
SKILL.md
.github/skills/supabase-flutter-idiomsView on GitHub ↗
---
name: supabase-flutter-idioms
description: >-
The current supabase_flutter v3 API for writing or reviewing Dart code that talks
to Supabase: client setup, typed queries with table(), typed rpc, auth,
realtime streams, error handling and logging. Use whenever code imports
supabase_flutter, or the user asks to query, insert, subscribe, sign in or handle
errors with Supabase. Prevents v2 patterns such as from() with maps,
anonKey, and realtime callbacks.
---
# Writing supabase_flutter v3 code
Most published Supabase Dart examples are v2 code. v3 changed many of the
APIs those examples use, so do not copy a pattern from memory without checking
it against this file. To upgrade an existing v2 project, use the
`supabase-flutter-migrate-v3` skill instead.
## Rules
- Query tables with `table()` and the generated schema, never with
`from()` and `Map<String, dynamic>`, unless the table is missing from the
generated file.
- Generate the schema with the Supabase CLI (2.122.0 or newer) and never edit
it by hand. Regenerate it after every database migration:
`supabase gen types --lang dart --local > lib/supabase_schema.g.dart`. The
`supabase-typegen-generate` skill, installed with the `supabase_typegen` dev
dependency, covers the setup and the options.
- Pass `publishableKey`. `anonKey` does not exist in v3.
- Listen to realtime with streams. v3 has no `callback:` parameters.
- Catch the `SupabaseException` hierarchy, never `ClientException`,
`TimeoutException` or `String`.
- A typed `update()` or `delete()` does nothing until `.where(…)` or
`.all()` is called on it. The analyzer reports a missing one as an
`unused_result` warning.
- Use one client, `Supabase.instance.client`, for the whole app.
## Client setup
```dart file
import 'package:flutter/widgets.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: 'https://your-project.supabase.co',
publishableKey: 'your-publishable-key',
);
runApp(const SizedBox());
}
final supabase = Supabase.instance.client;
```
`Supabase.initialize` persists the session with `SharedPreferencesAsync` and
restores it before it returns. Pass `FlutterAuthClientOptions(asyncStorage: …)`
to store it elsewhere, for example in secure storage.
## Reading rows
The generated file has, per table, a row type (`BooksRow`), insert and update
types (`BooksInsert`, `BooksUpdate`) and a class (`Books`) with the table
definition and one constant per column and relation. A table in another
schema carries the schema as a prefix, `InventoryBooks`.
```dart
final List<BooksRow> books = await supabase
.table(Books.table)
.select()
.where(Books.inPrint.isTrue() & Books.price.lt(10))
.order(Books.createdAt.desc())
.limit(20);
final String title = books.first.title;
final BooksRow book = await supabase
.table(Books.table)
.select()
.where(Books.id.eq(1))
.single();
```
Filters are methods on the column constants, combined with `&`, `|` and
`not()`. Values must match the column type, and `isNull()` only exists on
nullable columns. `order()` sorts ascending unless the ordering says
`.desc()`.
Read some columns only, or embed related rows, with `selectOnly`, and read
the values back through the same constants:
```dart
final books = await supabase.table(Books.table).selectOnly([
Books.title,
Books.authors(Authors.name),
]);
final String title = books.first.read(Books.title);
final String? authorName = books.first.read(Books.authors)?.read(Authors.name);
```
## Writing rows
```dart
await supabase
.table(Books.table)
.insert(BooksInsert(authorId: 2, title: 'Dune'));
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));
```
- Several rows go through `insertAll()` and `upsertAll()`.
- A `null` argument to `BooksInsert` or `BooksUpdate` leaves the column out.
Write SQL `NULL` with the generated `set…ToNull()` methods.
- Writes resolve to `void`. Add `.select()` to get the written rows back.
- `upsert()` takes `onConflict` as a list of columns,
`onConflict: [Authors.name]`.
## Calling database functions
Every function PostgREST exposes gets a typed method on `rpc`:
```dart
final BooksRow? book = await supabase.rpc.bookById(bookId: 1);
```
`supabase.rpc('fn', params: {...})` is the untyped fallback for functions the
generator gives no method, which it explains in a comment in the generated
file.
## Auth
Sign-in methods that always produce a session return the `Session`:
```dart
final session = await supabase.auth.signInWithPassword(
email: '[email protected]',
password: 'a-strong-password',
);
final user = session.user;
```
`signUp()` and `verifyOTP()` still return an `AuthResponse`, since they can
complete without a session.
`AuthState` is sealed, with one subtype per event. Every new listener first
receives `AuthInitialSession` with the current session or `null`:
```dart
final subscription = supabase.auth.onAuthStateChange.listen((state) {
switch (state) {
case AuthInitialSession(:final session):
// The session when this listener attached, or null.
print(session?.user.id);
case AuthSignedIn(:final session):
print('Signed in as ${session.user.email}');
case AuthSignedOut():
print('Signed out');
case _:
}
});
```
`user.userMetadata` is never null, and `user.appMetadata` is an
`AppMetadata` with typed `provider` and `providers`.
## Realtime
Keep a list of rows up to date with `stream()`:
```dart
final Stream<List<BooksRow>> books = supabase
.table(Books.table)
.stream(primaryKey: [Books.id])
.filter(Books.authorId.eq(2))
.order(Books.title);
```
The `supabase-flutter-realtime` skill covers broadcast, presence and channel
status in more depth.
React to individual changes with `onTableChanges`. Create the stream before
`subscribe()`, and remove the channel when done:
```dart
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();
await subscription.cancel();
await supabase.removeChannel(channel);
```
Broadcast and connection status are streams too:
```dart
final channel = supabase.channel('room');
channel.onBroadcast(event: 'cursor').listen((payload) => print(payload));
channel.onStatusChange.listen((change) async {
if (change.status == RealtimeSubscribeStatus.subscribed) {
await channel.sendBroadcastMessage(event: 'cursor', payload: {'x': 1});
}
});
channel.subscribe();
```
The table must be added to the `supabase_realtime` publication in the
database, or no changes arrive.
Create a stream once in `State`, never inside `build()`, or every rebuild
resubscribes:
```dart file
import 'package:flutter/widgets.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]);
@override
Widget build(BuildContext context) {
return StreamBuilder<List<BooksRow>>(
stream: _books,
builder: (context, snapshot) {
final books = snapshot.data ?? const [];
return Column(children: [for (final book in books) Text(book.title)]);
},
);
}
}
```
## 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:
```dart
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, such as `PostgrestException`,
`AuthException` or `StorageException`, to catch the errors of one service
only. `AuthApiException` and `PostgrestApiException` carry the server's
`errorCode`.
## Logging
The SDK never prints. It logs through `package:logging` under the `supabase`
logger, so listen to it to see the records:
```dart file
import 'package:logging/logging.dart';
void enableSupabaseLogs() {
Logger.root.onRecord.listen((record) {
if (record.loggerName.startsWith('supabase.')) {
print('${record.loggerName}: ${record.level.name}: ${record.message}');
}
});
}
```
## Tests
Use the `supabase_test` package as a dev dependency instead of mocking the
HTTP client by hand. Its `MockSupabaseHttpClient`, `testSupabaseClient` and
`signInTestUser` answer requests and put a client in a signed-in state. The
`supabase-test-testing` skill, installed with the `supabase_test` dev
dependency, covers stubbing, assertions, auth and realtime in tests.