Flutter Supabase Auth and CRUD with a Compatible Backend
Flutter Supabase auth and CRUD take four pieces: a table with a Row Level Security policy, a client initialized once with your project URL and anon key, signUp / signInWithPassword with an auth-state listener that swaps screens, and from('table') queries for insert, select, update and delete. Because the database enforces who owns which rows, the Flutter code never filters by user.
This guide builds a small todo app end to end. The code uses StitchDB's Flutter package, stitchdb_flutter, which has the same methods as supabase_flutter. If you already use supabase_flutter, the same calls work against a StitchDB project too: the backend speaks the same Auth, REST and Realtime APIs, so you only change the URL and key (compatibility).
#What you'll build
- E-mail and password sign-up and sign-in
- An auth gate that shows the login screen or the app
- Create, read, update and delete todos, only your own
- A live list that updates when rows change
- OAuth sign-in with a deep link back to the app
#1. Create the table and policy
In your project's SQL editor (or a migration):
create table public.todos (
id bigint generated always as identity primary key,
user_id uuid not null default auth.uid() references auth.users on delete cascade,
task text not null check (char_length(task) between 1 and 500),
is_complete boolean not null default false,
created_at timestamptz not null default now()
);
alter table public.todos enable row level security;
create policy "Users manage their own todos" on public.todos
for all to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());
create index on public.todos (user_id);
-- Send changes of this table to Realtime subscribers
alter publication stitchdb_realtime add table public.todos;user_id defaults to auth.uid(), so inserts from the app don't need to send it, and the with check clause stops anyone from writing a row for another user. On StitchDB the realtime publication is stitchdb_realtime; you can also tick Enable Realtime in Studio.
#2. Add the package
stitchdb_flutter is installed from GitHub. Add it to pubspec.yaml with the overrides from the Flutter SDK docs, which point its sub-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 } }Then run flutter pub get. It needs Dart 3.9 or newer.
#3. Initialize the client once
import 'package:flutter/material.dart';
import 'package:stitchdb_flutter/stitchdb_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Stitch.initialize(
url: 'https://<project-ref>.p.stitchdb.com',
anonKey: '<anon-key>',
);
runApp(const TodoApp());
}
final stitchdb = Stitch.instance.client;Use the anon key from Settings → API. It is safe in an app because RLS decides what it can do. Never put the service_role key in a Flutter build: it bypasses RLS and anyone can extract it from the binary.
In a real app, pass the values with --dart-define instead of hard-coding them, so staging and production builds point at different projects.
#4. Sign-up, sign-in and an auth gate
The auth gate listens to onAuthStateChange and shows the right screen. The session is stored on the device and refreshed automatically, so users stay signed in between launches.
class TodoApp extends StatelessWidget {
const TodoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: StreamBuilder(
stream: stitchdb.auth.onAuthStateChange,
builder: (context, _) {
final user = stitchdb.auth.currentUser;
return user == null ? const LoginPage() : const TodoPage();
},
),
);
}
}The login page calls two methods:
Future<void> signUp(String email, String password) async {
await stitchdb.auth.signUp(email: email, password: password);
// With "Confirm e-mail" on (the default), no session yet:
// tell the user to check their inbox.
}
Future<void> signIn(String email, String password) async {
try {
await stitchdb.auth.signInWithPassword(email: email, password: password);
} on AuthException catch (e) {
// e.statusCode, e.code, e.message: show a friendly error
debugPrint('Sign-in failed: ${e.message}');
}
}
Future<void> signOut() => stitchdb.auth.signOut();When Confirm e-mail is on, signUp returns no session and the user confirms through a link. Either keep it on and handle the link with a deep link (next step), or turn it off in Authentication → Sign-in methods while you develop.
#5. Deep links for e-mail links and OAuth
Magic links, confirmation e-mails and OAuth providers send the user back to your app through a deep link such as io.myapp://login-callback.
- Add
io.myapp://login-callbackto the project's Redirect URLs in Authentication → URL configuration (redirect URLs). - Register the scheme in your Android intent filter and iOS URL types.
- Pass it as the redirect:
await stitchdb.auth.signInWithOAuth(
OAuthProvider.github,
redirectTo: 'io.myapp://login-callback',
);
await stitchdb.auth.signInWithOtp(
email: '[email protected]',
emailRedirectTo: 'io.myapp://login-callback',
);The SDK opens the browser, receives the deep link and stores the session; the auth gate then switches to the app. For OAuth, set each provider's callback URL to https://<project-ref>.p.stitchdb.com/auth/v1/callback (OAuth providers).
If you'd rather not build auth screens at all, signInWithHostedPage(redirectTo: ...) opens the project's hosted sign-in page and returns the session through the same deep link.
#6. CRUD
Keep queries in one small repository class. Rows come back as List<Map<String, dynamic>>; map them to a model.
class Todo {
Todo({required this.id, required this.task, required this.isComplete});
final int id;
final String task;
final bool isComplete;
factory Todo.fromRow(Map<String, dynamic> r) =>
Todo(id: r['id'] as int, task: r['task'] as String, isComplete: r['is_complete'] as bool);
}
class TodoRepository {
Future<List<Todo>> list() async {
final rows = await stitchdb
.from('todos')
.select('id, task, is_complete')
.order('created_at', ascending: false)
.range(0, 49);
return rows.map(Todo.fromRow).toList();
}
Future<Todo> create(String task) async {
final row = await stitchdb.from('todos').insert({'task': task}).select().single();
return Todo.fromRow(row);
}
Future<void> toggle(Todo t) =>
stitchdb.from('todos').update({'is_complete': !t.isComplete}).eq('id', t.id);
Future<void> remove(int id) => stitchdb.from('todos').delete().eq('id', id);
Future<int> total() =>
stitchdb.from('todos').count(CountOption.exact);
}Notice what's missing: no .eq('user_id', ...) anywhere. The policy from step 1 limits every query to the signed-in user's rows, on reads and writes. If another user's id somehow ends up in a request, the database rejects it.
Handle database errors where you call the repository:
try {
await repo.create('');
} on PostgrestException catch (e) {
// The check constraint fails for an empty task: e.code is '23514'
showError(e.message);
}#7. A live list with Realtime
For a list that updates itself, use stream() on the table. It loads the current rows and then applies inserts, updates and deletes as they happen:
class TodoPage extends StatelessWidget {
const TodoPage({super.key});
@override
Widget build(BuildContext context) {
final stream = stitchdb.from('todos').stream(primaryKey: ['id']);
return Scaffold(
appBar: AppBar(title: const Text('Todos')),
body: StreamBuilder<List<Map<String, dynamic>>>(
stream: stream,
builder: (context, snap) {
if (!snap.hasData) return const Center(child: CircularProgressIndicator());
final todos = snap.data!.map(Todo.fromRow).toList();
return ListView(
children: [
for (final t in todos)
CheckboxListTile(
title: Text(t.task),
value: t.isComplete,
onChanged: (_) => TodoRepository().toggle(t),
),
],
);
},
),
);
}
}Realtime applies the same RLS policies: each subscriber only receives rows they're allowed to select. For finer control, subscribe to a channel with onPostgresChanges(event: PostgresChangeEvent.insert, schema: 'public', table: 'todos', callback: ...) and handle each event yourself.
#8. Check your security before shipping
- Open Advisors in Studio. It flags tables with RLS off and realtime tables without RLS (advisors).
- Use Database → Policies → Check access to see what a specific user can read or change.
- Sign in as two different test users and confirm neither sees the other's todos.
#Supabase or StitchDB for a Flutter app
Both work with the same Flutter code shape. Supabase has the official supabase_flutter package on pub.dev, more tutorials and regions worldwide; if your users are in the Americas or Asia, that matters. StitchDB hosts in the EU only and adds mobile-oriented features in the same SDK: push notifications, in-app purchase verification, feature flags and offline sync with a conflict rule per table (offline sync). Moving an existing Supabase project over keeps users' passwords and ids (import). For a wider comparison, see the Flutter backend guide.
#FAQ
#Can I use supabase_flutter with StitchDB?
Yes. StitchDB speaks the same Auth, REST, Storage and Realtime APIs. Initialize supabase_flutter with your StitchDB project URL and pass the anon key as its key (recent versions name that parameter publishableKey).
#Why does my Flutter query return an empty list?
RLS is on and no policy allows the request, or the user isn't signed in yet. Check that a session exists and that a select policy matches.
#How do I keep users signed in between app launches?
The Flutter package stores the session on the device and refreshes the access token automatically. Read auth.currentUser at startup.
#Do I need to send user_id when inserting?
Not if the column defaults to auth.uid(). The with check policy also prevents writing rows for other users.
#How do I get live updates in Flutter?
Use from('todos').stream(primaryKey: ['id']) with a StreamBuilder, after adding the table to the realtime publication.
Build it on a free StitchDB project, or bring your existing app with the compatibility guide.