<< All versions

Skill v1.0.0

currentLLM-judged scan90/100
supabase/supabase-flutter/supabase-flutter-realtime
──Details
PublishedOctober 23, 2026 at 03:29 AM
Content Hashsha256:f840e2fb8124734c...
Git SHA3f415f4e88a7
──Files
Files (1 file, 6.5 KB)
SKILL.md6.5 KBactive
SKILL.md · 195 lines · 6.5 KB

version: "1.0.0" name: supabase-flutter-realtime description: >- Builds realtime features with supabase_flutter v3: live lists of table rows with typed stream(), reacting to inserts, updates and deletes with onTableChanges, broadcast messages between clients, presence, and channel status and cleanup. Use when the user wants live updates, a chat, cursors, an online indicator or notifications of database changes.


Realtime with supabase_flutter

Choose the API

NeedUse
The current rows of a table, kept up to datetable(X.table).stream(primaryKey: [...])
Only some columns of those rowstable(X.table).streamOnly(...)
Every insert, update or delete as an eventchannel.onTableChanges(X.table)
Messages between clients that are not stored in a tablechannel.onBroadcast and sendBroadcastMessage
Who is onlinechannel.track and onPresenceSync

Rules

  • Every listener is a Stream. There are no callback: parameters.
  • Create every onTableChanges, onBroadcast and presence stream before

channel.subscribe(). A postgres changes listener added after it throws a StateError.

  • Cancel the subscriptions and call supabase.removeChannel(channel) when the

feature is closed. A forgotten channel keeps receiving messages.

  • Use the typed table definitions from the generated schema, not table names

as strings.

  • Database changes only arrive for tables in the supabase_realtime

publication, and only for rows the user may select under row level security.

  • Create streams once, in initState or a field of State, never in

build(), or every rebuild opens a new subscription.

A live list of rows

stream() fetches the matching rows, then applies every change, and emits the whole list each time:

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

filter() takes one operator: eq, neq, lt, lte, gt, gte, inFilter, like, ilike, matchRegex, imatchRegex, isNull, isTrue, isFalse or isDistinct, optionally negated with not(). A filter composed with & or | throws an ArgumentError.

order() takes the same orderings as a query, Books.title or Books.createdAt.desc(). A stream keeps NULLs last ascending and first descending, so .nullsFirst(), .nullsLast(), or an ordering by anything but a stored column, such as a computed field, throws an ArgumentError.

To receive only some columns:

dart
final titles = supabase
.table(Books.table)
.streamOnly(primaryKey: [Books.id], columns: [Books.title])
.map((rows) => [for (final row in rows) row.read(Books.title)]);
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) {
if (snapshot.hasError) return Text('${snapshot.error}');
final books = snapshot.data ?? const [];
return Column(children: [for (final book in books) Text(book.title)]);
},
);
}
}

Reacting to changes

dart
final channel = supabase.channel('books-by-author-2');
final subscription = channel
.onTableChanges(
Books.table,
event: PostgresChangeEvent.insert,
filters: [Books.authorId.eq(2)],
)
.listen((change) {
if (change case TypedPostgresInsert(:final newRow)) {
print('New book: ${newRow.title}');
}
});
channel.subscribe();

Without event, the stream carries TypedPostgresInsert, TypedPostgresUpdate and TypedPostgresDelete, so switch over all three. The old row of an update or delete only carries the primary key, unless the table has replica identity full. onTableChangesOnly(…, columns: [...]) asks the server for fewer columns.

Broadcast

dart
final channel = supabase.channel('room-1');
channel
.onBroadcast(event: 'cursor')
.listen((payload) => print('Cursor at ${payload['x']}'));
channel.subscribe();
await channel.sendBroadcastMessage(event: 'cursor', payload: {'x': 30});

Pass RealtimeChannelConfig(self: true) as options to supabase.channel to receive your own broadcasts, and private: true for a channel guarded by realtime authorization policies.

Presence

dart
final channel = supabase.channel('room-1');
channel.onPresenceSync.listen((_) {
print('Online: ${channel.presenceState().length}');
});
channel.onStatusChange.listen((change) async {
if (change.status == RealtimeSubscribeStatus.subscribed) {
await channel.track({'user_id': 'user-1'});
}
});
channel.subscribe();

Track only after the channel is subscribed, as above. untrack() removes the current client's state.

Status, errors and cleanup

onStatusChange reports the subscription status, and errors such as a join the server declined arrive as RealtimeException errors on it:

dart
final channel = supabase.channel('room-1');
channel.onStatusChange.listen(
(change) => print('Channel is ${change.status.name}'),
onError: (Object error) => print('Channel failed: $error'),
);
channel.subscribe();
await supabase.removeChannel(channel);

supabase.removeAllChannels() closes every channel, for example on sign out. A typed stream() reports its failures, including a RealtimeException, as errors of the stream itself.

The app lifecycle

Supabase disconnects the realtime socket when the app is paused and reconnects, rejoining every channel, when it resumes. Keep the socket open through short pauses, such as a full-screen ad on iOS, with RealtimeLifecycleOptions:

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