The StitchDB Dart and Flutter clients: database, auth with deep links and the hosted sign-in page, storage, realtime, Edge Functions, errors, push, purchases, organizations, flags, jobs, metering, collaborative documents and serverless SQL.
#Install
Two packages: stitchdb_flutter for Flutter apps (keeps the session on the device, handles deep links and OAuth) and stitchdb for plain Dart (servers, CLIs). Add the package from GitHub in pubspec.yaml; the overrides point its own packages at the same repository:
dependencies:
stitchdb_flutter:
git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_flutter }
dependency_overrides:
stitchdb: { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb } }
stitchdb_auth: { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_auth } }
stitchdb_common: { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_common } }
stitchdb_functions: { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_functions } }
stitchdb_postgrest: { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_postgrest } }
stitchdb_realtime: { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_realtime } }
stitchdb_storage: { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_storage } }For plain Dart, use stitchdb (path packages/stitchdb) under dependencies instead, with the same overrides minus stitchdb. Then flutter pub get or dart pub get. Dart 3.9 or newer. This gets the current version, 1.4.0 (stitchdb_flutter and stitchdb).
#Create a client
import 'package:stitchdb_flutter/stitchdb_flutter.dart';
Future<void> main() async {
await Stitch.initialize(url: 'https://<project-ref>.p.stitchdb.com', anonKey: '<anon-key>');
runApp(const MyApp());
}
final stitchdb = Stitch.instance.client;// Plain Dart
import 'package:stitchdb/stitchdb.dart';
final stitchdb = StitchClient('https://<project-ref>.p.stitchdb.com', '<anon-key>');#Database
// Select with filters, order and paging (rows are List<Map<String, dynamic>>)
final todos = await stitchdb
.from('todos')
.select('id, task, is_complete')
.eq('is_complete', false)
.order('created_at', ascending: false)
.range(0, 9);
final one = await stitchdb.from('todos').select().eq('id', 1).single();
final count = await stitchdb.from('todos').count(CountOption.exact);
// Insert (returning the row), update, upsert, delete
final created = await stitchdb.from('todos').insert({'task': 'Ship it'}).select().single();
await stitchdb.from('todos').update({'is_complete': true}).eq('id', created['id']);
await stitchdb.from('todos').upsert({'id': 1, 'task': 'Ship it today'});
await stitchdb.from('todos').delete().eq('id', 1);
// Functions
final open = await stitchdb.rpc('count_open_todos', params: {'owner': userId});Filters: eq, neq, gt, gte, lt, lte, like, ilike, inFilter, contains, textSearch, or('status.eq.open,priority.gt.3'), not, match. A stream() on a table gives its rows live: stitchdb.from('todos').stream(primaryKey: ['id']).
#Auth
await stitchdb.auth.signUp(email: '[email protected]', password: 'a-strong-password');
final res = await stitchdb.auth.signInWithPassword(email: '[email protected]', password: 'a-strong-password');
final user = stitchdb.auth.currentUser;
// Magic link / one-time code, and OAuth (Flutter: opens the browser, returns by deep link)
await stitchdb.auth.signInWithOtp(email: '[email protected]', emailRedirectTo: 'io.myapp://login-callback');
await stitchdb.auth.signInWithOAuth(OAuthProvider.github, redirectTo: 'io.myapp://login-callback');
await stitchdb.auth.updateUser(UserAttributes(data: {'display_name': 'Ada'}));
await stitchdb.auth.resetPasswordForEmail('[email protected]');
await stitchdb.auth.signOut();
stitchdb.auth.onAuthStateChange.listen((data) {
final event = data.event; // AuthChangeEvent.signedIn, signedOut, tokenRefreshed, ...
final session = data.session;
});Add your app's deep link (for example io.myapp://login-callback) to the redirect URLs of the project and to the Android/iOS app configuration. Admin calls (auth.admin.createUser, listUsers, deleteUser) need a client with the service_role key, on a server only.
#Storage
final bucket = stitchdb.storage.from('avatars');
await bucket.upload('user-1/avatar.png', File('avatar.png'),
fileOptions: const FileOptions(contentType: 'image/png', upsert: true));
await bucket.uploadBinary('user-1/avatar.png', bytes); // Uint8List, e.g. on the web
final data = await bucket.download('user-1/avatar.png');
final publicUrl = bucket.getPublicUrl('user-1/avatar.png'); // public buckets
final signedUrl = await bucket.createSignedUrl('user-1/avatar.png', 60); // private
final files = await bucket.list(path: 'user-1');
await bucket.move('user-1/avatar.png', 'user-1/old.png');
await bucket.remove(['user-1/old.png']);#Realtime
// Database changes
final changes = stitchdb.channel('todos-changes')
.onPostgresChanges(
event: PostgresChangeEvent.insert, schema: 'public', table: 'todos',
callback: (payload) => print(payload.newRecord),
)
.subscribe();
// Broadcast
final room = stitchdb.channel('room-1')
.onBroadcast(event: 'cursor', callback: (payload) => print(payload))
.subscribe();
await room.sendBroadcastMessage(event: 'cursor', payload: {'x': 10, 'y': 20});
// Presence
room.onPresenceSync((_) => print(room.presenceState()));
await room.track({'user': 'ada'});
await stitchdb.removeChannel(room);#Edge Functions
final res = await stitchdb.functions.invoke('hello-world', body: {'name': 'Ada'});
print(res.data); // parsed JSON (or text / bytes), res.status#Errors
try {
await stitchdb.from('todos').insert({'task': null});
} on PostgrestException catch (e) {
print('${e.code} ${e.message} ${e.details} ${e.hint}');
} on AuthException catch (e) {
print('${e.statusCode} ${e.code} ${e.message}');
}Errors are thrown as exceptions: PostgrestException, AuthException (and AuthApiException, AuthWeakPasswordException, ...), StorageException and FunctionException. See Errors.
#Hosted sign-in page
await stitchdb.auth.signInWithHostedPage(
redirectTo: 'io.myapp://login-callback',
view: HostedPageView.signUp, // optional: signIn, signUp, magicLink or forgot
lang: 'de', // optional
);Opens the project's hosted sign-in page: sign-in, sign-up, e-mail links and codes, password reset and two-step codes, with no auth screens to build. The session comes back through your deep link, like after an OAuth sign-in. In plain Dart, getHostedPageUrl(redirectTo:, view:, lang:) gives the URL, and getSessionFromUrl exchanges the sdbl_ code it sends back.
When the project turns on its built-in sign-up check, signUp, signInAnonymously and signInWithOtp solve it (off the main isolate) and retry once. There is no widget to add. AuthCaptchaRequiredException and solveSignupCheck are there for your own requests.
Sign-ups send the device locale as locale in the user metadata (unless it already has locale, language or lang), so auth e-mails go out in the user's language. Set signUpLocale to change it.
#Push notifications
// After sign-in and on every new token
await stitchdb.push.register(PushDevice.android(fcmToken, topics: ['news']));
await stitchdb.push.subscribe('sale');
// The in-app inbox
final inbox = await stitchdb.push.inbox(unread: true);
await stitchdb.push.markAllRead();
final stop = await stitchdb.push.onNotification((n) => showBanner(n.title));
// Server side, with the service key
final id = await admin.push.send(PushMessage(userId: userId, title: 'Your order shipped'));Methods: register, unregister, subscribe, unsubscribe, inbox, markRead, markAllRead, deleteNotifications, onNotification, config, and with the service key send and status. Devices: PushDevice.ios (APNs token as hex, or PushDevice.apnsBytes), PushDevice.android (FCM token), PushDevice.web. Errors are PushException. In these examples admin is a client created with the service_role key, on a server only. See Push notifications.
#In-app purchases
final r = await stitchdb.purchases.sync(
PurchaseInput.google(purchaseToken: token, productId: 'pro_monthly'),
);
await stitchdb.purchases.sync(PurchaseInput.apple(signedTransaction: jws));
if (await stitchdb.purchases.hasEntitlement('pro')) unlockPro();Methods: sync, me (purchases and entitlements), hasEntitlement, config, and with the service key refresh(userId). See In-app purchases.
#Organizations
final org = await stitchdb.orgs.create(name: 'Acme');
await stitchdb.orgs.invite(org.id, email: '[email protected]', role: 'admin');
await stitchdb.orgs.setActive(org.id); // the JWT now has org_id and org_role
stitchdb.orgs.useOrg(org.id); // or: send x-org-id with every requestMethods: create, update, delete, list, members, invite, invites, revokeInvite, acceptInvite(token), setRole, removeMember, leave, transferOwnership, setActive, useOrg. Errors are OrgsException. See Organizations.
#Realtime message history
final chat = stitchdb.channel('chat:42',
opts: const RealtimeChannelConfig(history: RealtimeHistory()));
chat.onBroadcast(event: 'message', callback: (m) => show(m['payload'])).subscribe();
await chat.publish('message', {'text': 'Hi'}); // stored, then broadcastAfter every rejoin the channel gets the broadcasts it missed first, in order, then the live ones. Replayed messages have meta.replayed set to true. RealtimeHistory(since: ...) also replays older messages on the first join. See Message history.
#Search
final r = await stitchdb.search('products', 'red shoes', limit: 10, mode: 'hybrid');
for (final row in r.rows) print(row['title']);mode is hybrid, keyword or semantic. r.scores has the score of each row and r.note says when the search fell back. See Search.
#Feature flags
await stitchdb.flags.load(attributes: {'app_version': '2.4.1'});
if (stitchdb.flags.isOn('new-checkout')) showNewCheckout();
final limit = stitchdb.flags.get('upload-limit-mb', 10);
stitchdb.flags.changes.listen((keys) => rebuild());Methods: load, refresh, setAttributes, get, isOn, detail, all, the changes stream, and with the service key evaluate(FlagUser(...)). Values are fetched again on sign-in, sign-out and every minute. See Feature flags.
#Background jobs
// Service key, on your server
final run = await admin.jobs.start('onboarding',
payload: {'user_id': id}, idempotencyKey: 'onboarding:$id');
final now = await admin.jobs.get(run.id); // steps, status, resultMethods: start (idempotency key, delay, run time, attempts, long mode), get, list, cancel, retry, logs. See Background jobs.
#Usage metering
await admin.metering.record('api_calls', customer: 'acme', quantity: 3);
final usage = await admin.metering.usage(customer: 'acme', group: 'month');
final csv = await admin.metering.exportCsv(meter: 'api_calls');Methods: record, recordMany, usage, customers, exportCsv. See Usage metering.
#Collaborative documents
final collab = stitchdb.collab(MyYDocAdapter());
final room = await collab.open('plan-2026', doc, create: true, title: 'Plan 2026');
room.setPresence({'name': 'Ana', 'cursor': 4});
room.onPresence.listen(drawCursors);
await room.snapshot(label: 'Before review');Documents sync over one WebSocket: stored updates load on open, edits go out as they happen, and edits made offline are sent after a reconnect. The SDK does not pick a CRDT library: implement CollabAdapter<D> (newDoc, applyUpdate, encodeStateAsUpdate, mergeUpdates, observeUpdates) over a Yjs-compatible Dart package. Rooms also have snapshots, loadSnapshot and close; the client has list, members and share. See Collaborative documents.
#GraphQL
final r = await stitchdb.graphql(
r'query($n: Int) { todosCollection(first: $n) { edges { node { id title } } } }',
variables: {'n': 10});
if (r.hasErrors) print(r.errors.first.message);Returns GraphQLResponse with data and errors. See GraphQL.
#Status page
final s = await stitchdb.status.get(); // components, 90-day history, incidents
if (s.overall != 'operational') showBanner(s.overallLabel);
await stitchdb.status.subscribe('[email protected]');See Status page.
#Serverless SQL
final sql = SqlClient.fromUrl(Platform.environment['STITCHDB_SQL_URL']!);
final r = await sql.query(r'select * from todos where owner = $1', [userId]);
await sql.transaction([
SqlStatement(r'update stock set left = left - 1 where sku = $1', [sku]),
SqlStatement(r'insert into orders (sku) values ($1)', [sku]),
]);
final db = await sql.connect(); // WebSocket session: interactive transactions
await db.transaction((tx) async {
await tx.query(r'update accounts set balance = balance - $1 where id = $2', [amount, id]);
});
await db.close();query and transaction are one HTTP request each; connect() opens a SqlSession with query, begin, transaction and close. Errors are SqlException with the Postgres fields. See Serverless driver.