Skip to content
Flutter+Supabase
Stack Integration

Flutter + Supabase: Multi-Tenant Architecture

Flutter on the frontend, Supabase on the backend — the default stack for cross-platform SaaS MVPs.

Use Cases
  1. Multi-tenant mobile apps with per-tenant data isolation
  2. Real-time data sync via Supabase Realtime in Flutter
  3. Auth with Supabase + Flutter deep link handling
  4. Offline-capable apps with local SQLite + Supabase sync
Implementation

Row-Level Security policies in Postgres enforce tenant isolation at the database level. Real-time subscriptions use Supabase's PostgreSQL LISTEN/NOTIFY under the hood.

In detail

How the pieces connect

The Flutter app talks to Supabase through the supabase_flutter package, which wraps the auto-generated PostgREST API, GoTrue auth, Realtime, and Storage behind one Supabase.instance.client. You initialize it once in main() with your project URL and anon key, then read and write tables with the query builder — supabase.from('bookings').select() compiles to a PostgREST request, not raw SQL from the device. The anon key is public by design; it carries no privileges of its own. What a request can actually touch is decided server-side by Row-Level Security policies that read auth.uid() from the verified JWT. So the real authorization boundary lives in Postgres, not in Dart. The service-role key, which bypasses RLS, must never ship inside the Flutter binary — keep it in Edge Functions or your own server only.

Multi-tenant isolation with RLS

For per-tenant separation you typically add a tenant_id (or org_id) column to every shared table and write RLS policies that compare it against the caller's identity. A common pattern stores the tenant link in a memberships table and writes a USING clause like tenant_id IN (SELECT tenant_id FROM memberships WHERE user_id = auth.uid()). Enable RLS explicitly with ALTER TABLE bookings ENABLE ROW LEVEL SECURITY — a table with RLS on but no policy denies everything, while a table with RLS off is fully open to the anon key. Write separate policies per command (SELECT, INSERT, UPDATE, DELETE); INSERT needs a WITH CHECK clause so a client can't write rows into a tenant it doesn't belong to. Index the tenant column, since every policy subquery runs on each row touched.

Realtime and offline gotchas

Supabase Realtime streams Postgres changes over WebSocket, exposed in Flutter via .stream(primaryKey: ['id']) or channel subscriptions. Two non-obvious points: Realtime authorization is configured separately from table RLS, so a row a user can SELECT over REST won't necessarily arrive over the socket unless Realtime RLS is set up too. And you must enable replication on each table you want to broadcast — it's off by default. For offline, supabase_flutter does not sync automatically; you keep a local store (SQLite/Drift/Isar) as the source of truth on-device and reconcile on reconnect, handling conflicts yourself. Watch session lifecycle: the SDK refreshes the JWT in the background, but a token expiring while the app is suspended can drop the socket — listen to onAuthStateChange and resubscribe on resume.

Auth deep links and secrets

OAuth and magic-link sign-in send the user to a browser, then redirect back into the app via a custom URL scheme or App Link / Universal Link. Register that redirect in the Supabase dashboard's allow-list and wire the matching intent-filter (Android) and associated-domain / CFBundleURLTypes (iOS); a mismatch is the usual cause of a login that completes in the browser but never returns to Flutter. supabase_flutter captures the callback and persists the session automatically. Only the project URL and anon key belong in the client — feed them through --dart-define rather than hardcoding, and remember the binary is decompilable, so the anon key is not a secret and RLS must carry the weight. Privileged work (webhooks, payment confirmation, admin tasks) runs in Edge Functions where the service-role key stays server-side.

Live ExampleBookBed SaaS — Case Study
Other integration guidesView all →
Related

Need this built?