# What You’ll Build (and Why It Matters)#
This guide shows how to ship Flutter apps with Supabase in production, focusing on the four areas that usually break after launch: authentication flows, secure data access with Row Level Security, realtime updates, and offline-friendly data access.
If you’re deciding between Firebase and Supabase, read our Flutter Firebase tutorial first to understand what Firebase gives you out of the box, especially offline sync, then use this guide to implement similar UX on Supabase.
You’ll also see how to structure the code so auth, data, and sync concerns don’t leak across your codebase. For architecture options, reference Flutter app architecture: clean architecture, feature-first.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| Flutter | 3.19+ | Stable recommended |
| Dart | 3.3+ | Comes with Flutter |
| Supabase project | Any | Enable Email auth and Realtime |
| supabase_flutter | Latest | Official client for Flutter |
| Local storage | drift or isar | For offline cache and outbox |
| Secure storage | flutter_secure_storage | For tokens and sensitive flags |
ℹ️ Note: Supabase Auth sessions are persisted by
supabase_flutter. Still, you should understand where and how tokens are stored, and how to handle session refresh, logout, and multi-device edge cases.
# 1) Data Model That Survives Production#
Before you write Flutter code, lock down a schema that supports RLS and offline sync. A typical “tasks” example is enough to prove the patterns.
Recommended schema#
You want these columns almost every time:
idUUID primary keyuser_idUUID ownercreated_at,updated_atdeleted_atnullable timestamp for soft deleteversioninteger for conflict detectionclient_updated_attimestamp set by the client, used for reconciliation
In PostgreSQL terms:
| Column | Type | Why |
|---|---|---|
| id | uuid | Stable ID for offline-created records |
| user_id | uuid | RLS ownership checks |
| updated_at | timestamptz | Server-side source of truth for ordering |
| client_updated_at | timestamptz | Helps when offline edits happen across devices |
| version | int | Detect lost updates, support merge strategies |
| deleted_at | timestamptz nullable | Tombstones for sync and realtime |
💡 Tip: Prefer UUIDs generated client-side for offline creates. You can generate them in Flutter and insert later, so the UI can navigate to details screens without waiting for the network.
# 2) Supabase Auth in Flutter: Flows That Don’t Bite Later#
Production auth is mostly about edge cases: session refresh, deep links, multiple providers, and “half logged-in” states.
2.1 Installing and initializing Supabase#
// main.dart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: const String.fromEnvironment('SUPABASE_URL'),
anonKey: const String.fromEnvironment('SUPABASE_ANON_KEY'),
);
runApp(const MyApp());
}
final supabase = Supabase.instance.client;Avoid hardcoding keys. Use --dart-define per environment.
flutter run \
--dart-define=SUPABASE_URL=your-url \
--dart-define=SUPABASE_ANON_KEY=your-anon-key2.2 Email and password (with production UX)#
In production, the minimum is:
- validate email format and password length client-side
- show clear error messages for rate limit and invalid credentials
- confirm email if required
- support password reset
Future<void> signInWithEmail(String email, String password) async {
final res = await supabase.auth.signInWithPassword(
email: email,
password: password,
);
if (res.session == null) {
throw Exception('No session returned');
}
}For sign up:
Future<void> signUp(String email, String password) async {
await supabase.auth.signUp(
email: email,
password: password,
data: {'marketing_opt_in': false},
);
}2.3 OAuth providers and deep links#
If you support Google or Apple, test deep links on:
- Android: multiple browsers, custom tabs
- iOS: Safari and in-app browser
- cold start and warm start
Make sure your redirect URLs are correctly configured in Supabase and your app.
⚠️ Warning: OAuth “works on my device” often fails in production because of incorrect redirect URLs, missing SHA-256 fingerprint for Android, or Universal Links not matching the production bundle ID. Test a release build early.
2.4 Session lifecycle: listen once, route correctly#
You need a single source of truth for auth state.
Stream<AuthState> authStateChanges() {
return supabase.auth.onAuthStateChange.map((e) => e);
}Routing logic should distinguish between:
- signed out
- signed in
- token refreshing
- password recovery or email confirmation flows
For production, implement “soft logout” on auth errors: clear local cache only when you’re sure the user is signed out, not when a transient network error occurs.
2.5 Recommended pattern: AuthRepository + session guard#
Tie this back to clean architecture so UI doesn’t call Supabase directly. For a feature-first approach, follow our Flutter app architecture guide.
| Layer | Responsibility | Should know Supabase client |
|---|---|---|
| UI | Forms, routing, view state | No |
| AuthRepository | Sign-in/out, session stream | Yes |
| Use cases | “SignIn”, “SignOut” orchestration | No |
| Data sources | Supabase API calls | Yes |
# 3) Secure Data Access with RLS: The Non-Negotiable Part#
RLS is why Supabase is production-grade for mobile. Without it, anyone can query your database by copying your anon key.
3.1 Enable RLS and deny by default#
For each table exposed to the client:
- 1enable RLS
- 2create explicit policies for select, insert, update, delete
In SQL:
alter table public.tasks enable row level security;
-- Read only your own tasks
create policy "tasks_select_own"
on public.tasks for select
using (auth.uid() = user_id);
-- Insert only with your user_id
create policy "tasks_insert_own"
on public.tasks for insert
with check (auth.uid() = user_id);
-- Update only your own
create policy "tasks_update_own"
on public.tasks for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- Soft delete only your own
create policy "tasks_delete_own"
on public.tasks for delete
using (auth.uid() = user_id);3.2 Avoid trusting client-provided user_id#
Even with RLS, you should reduce footguns. Use a database default for user_id where possible.
alter table public.tasks
alter column user_id set default auth.uid();Now the client doesn’t need to send user_id on insert, and you remove an entire class of mistakes.
🎯 Key Takeaway: Your Flutter app is an untrusted client. Enforce ownership and permissions in PostgreSQL, not in Dart.
3.3 Multi-tenant data: teams and memberships#
If your app has teams, you’ll need membership tables and join-based policies.
A common layout:
| Table | Key columns | Purpose |
|---|---|---|
| teams | id, created_by | Team entity |
| team_members | team_id, user_id, role | Membership and role |
| tasks | id, team_id, created_by | Team-scoped content |
Policy idea: allow access if user is a member of the team.
create policy "tasks_select_team_member"
on public.tasks for select
using (
exists (
select 1
from public.team_members m
where m.team_id = tasks.team_id
and m.user_id = auth.uid()
)
);3.4 Common RLS gotchas#
- 1
Forgetting
with checkon insert and update
usingcontrols which rows are visible or targetable, whilewith checkcontrols what new data is allowed. - 2
Using security definer functions incorrectly
If you create RPC functions, understand whether they bypass RLS. Keep them minimal and audited. - 3
Realtime and RLS mismatch
Realtime events can appear “missing” if the user isn’t allowed to see the row under RLS. That’s correct behavior, but it looks like a bug during testing.
# 4) Realtime in Flutter: Subscriptions That Scale#
Supabase Realtime is great for collaborative updates, live dashboards, and chat-like experiences. It can also destroy performance if you subscribe too broadly.
4.1 Subscribe narrowly and only when needed#
Example: listen to tasks for a single team.
RealtimeChannel subscribeToTasks(String teamId, void Function(Map<String, dynamic>) onChange) {
final channel = supabase.channel('tasks:$teamId');
channel.onPostgresChanges(
event: PostgresChangeEvent.all,
schema: 'public',
table: 'tasks',
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: 'team_id',
value: teamId,
),
callback: (payload) => onChange(payload.newRecord),
);
channel.subscribe();
return channel;
}Lifecycle rule: subscribe on screen enter, unsubscribe on dispose.
4.2 Realtime + local cache: treat realtime as invalidation#
If you’re offline-first, realtime should not be your primary data source. Use it to invalidate or patch your local cache.
A practical rule:
- if you have the row locally, merge the payload
- if you don’t, schedule a background fetch for that page or query
4.3 Throttle UI updates#
If you subscribe to high-frequency changes, don’t rebuild the entire list for every event. Batch updates for 100 to 300 ms, then apply a single state update.
⚠️ Warning: Subscribing to “all rows in a table” is the fastest way to create battery drain, excessive rebuilds, and hard-to-debug data leaks if your filters are wrong.
# 5) Offline-Friendly Data Access: What Supabase Doesn’t Do For You#
Firebase’s killer feature for many apps is built-in offline persistence and conflict resolution. Supabase is PostgreSQL-first, so you need to implement offline UX intentionally.
For conflict strategies and real-world examples, read Flutter offline-first sync conflict resolution.
5.1 Target UX: “instant UI” with eventual consistency#
A realistic offline-first target looks like this:
- reads are served from local DB instantly
- writes update local DB immediately and enqueue an outbox event
- a sync worker retries in the background
- conflicts are detected and resolved predictably
5.2 Recommended local storage approach#
For production Flutter apps, two common choices:
| Local DB | Strength | When to choose |
|---|---|---|
| drift (SQLite) | Strong querying, migrations, relational modeling | Business apps with complex filters |
| isar | Fast object store, simple persistence | Simpler schemas, performance first |
If you already rely on SQL on the server, drift often feels natural because you can mirror query patterns locally.
5.3 Outbox table: queue writes while offline#
Your local DB should include an outbox table with:
| Field | Example | Purpose |
|---|---|---|
| id | UUID | Unique event ID |
| entity | "tasks" | Which table |
| entity_id | UUID | Which row |
| op | "insert" or "update" or "delete" | Operation type |
| payload | JSON string | Data to send |
| created_at | timestamp | Ordering |
| retry_count | int | Backoff |
| last_error | string | Debugging |
When user edits a task offline:
- 1update local
tasksrow immediately - 2enqueue an outbox event
- 3mark row as
dirty = true
5.4 Sync worker: retry with backoff#
Keep it simple:
- run on app start
- run when connectivity returns
- run periodically while app is active
Pseudo-implementation outline:
Future<void> syncOutbox() async {
final events = await localDb.outbox.getPending(limit: 50);
for (final e in events) {
try {
await pushEventToSupabase(e);
await localDb.outbox.markDone(e.id);
await localDb.entities.clearDirty(e.entityId);
} catch (err) {
await localDb.outbox.markFailed(e.id, err.toString());
}
}
}5.5 Pushing changes to Supabase safely#
For updates, include version to avoid overwriting newer data. One approach:
- client sends
version - server only applies update if version matches
- server increments version
This is optimistic concurrency control.
On the server, you can enforce it with an RPC or a conditional update.
If you use a conditional update from Flutter, your update should target the row and the expected version.
Future<void> updateTaskWithVersion(String id, int expectedVersion, Map<String, dynamic> patch) async {
final update = {
...patch,
'client_updated_at': DateTime.now().toUtc().toIso8601String(),
};
final res = await supabase
.from('tasks')
.update(update)
.eq('id', id)
.eq('version', expectedVersion)
.select('id, version')
.maybeSingle();
if (res == null) {
throw Exception('Conflict: version mismatch');
}
}Then handle conflicts in your sync worker:
- fetch latest server row
- compare with local dirty row
- apply your strategy: last-write-wins, merge fields, or user prompt
5.6 Pulling server changes efficiently#
Don’t refetch everything on every sync. Use updated_at watermarks.
Store a per-table checkpoint locally:
| Checkpoint | Example | Meaning |
|---|---|---|
| tasks_last_sync | 2026-06-02T10:12:00Z | Last successful pull |
Then pull updates:
- fetch rows with
updated_atgreater than checkpoint - apply upserts locally
- advance checkpoint to max
updated_atreturned
Future<List<Map<String, dynamic>>> fetchTasksSince(String teamId, String sinceIso) async {
return await supabase
.from('tasks')
.select('id, team_id, title, updated_at, deleted_at, version')
.eq('team_id', teamId)
.gt('updated_at', sinceIso)
.order('updated_at');
}5.7 Soft deletes and tombstones#
Hard delete breaks offline reconciliation because other devices may miss the delete event. Prefer soft delete:
- set
deleted_at - exclude deleted rows in queries by default
- periodically purge deleted rows server-side if needed
In Flutter, treat delete as:
- mark local row
deleted_at = nowand enqueue outbox delete event - hide it immediately from UI
- sync later
# 6) Putting It Together: A Production Data Access Stack#
A stable pattern for Flutter Supabase auth realtime offline sync looks like this:
6.1 Repository pattern with local-first reads#
Rules of thumb:
- UI reads from local DB streams
- repository exposes
watchmethods returning streams - remote fetch updates local DB, not the UI directly
| Method type | Source | Example |
|---|---|---|
watchTasks(teamId) | Local DB | UI list |
refreshTasks(teamId) | Remote then local | Pull-to-refresh |
editTask(task) | Local then outbox | Instant edits |
sync() | Outbox then pull | Background worker |
6.2 Realtime integration point#
Realtime should call into the repository to patch local DB.
Example behavior on event:
- if payload is a delete with
deleted_at, mark as deleted locally - else upsert row locally
- do not navigate or show snackbars automatically, let UI decide
6.3 Recommended libraries#
| Concern | Recommendation | Why |
|---|---|---|
| State management | Riverpod | Testability, scoping, async ergonomics |
| Local DB | drift or isar | Mature, production-proven |
| Connectivity | connectivity_plus | Detect network changes |
| Background work | workmanager (Android), background_fetch (iOS limits) | Best-effort background sync |
| Logging | talker or logger | Debugging sync and auth issues |
💡 Tip: Build a “Sync Debug” screen in debug builds. Show outbox size, last sync time, last error, and current user id. This reduces production debugging time dramatically.
# 7) Common Gotchas in Production (and How to Avoid Them)#
7.1 Using the service role key in the app#
Never ship the service role key to clients. Use anon key only.
If you need privileged operations:
- use Supabase Edge Functions
- or server-side API under your control
- keep RLS policies strict
7.2 Realtime seems unreliable#
Most “realtime is broken” issues are one of these:
- RLS prevents the user from seeing the row
- user is subscribed to wrong schema or table
- filter mismatches, especially wrong column types
- channel not unsubscribed, multiple subscriptions duplicate events
Fix by logging:
- channel name
- subscription status
- payload ids and team ids
- current auth user id
7.3 Timezone and ordering bugs#
Use UTC everywhere:
- send
DateTime.now().toUtc() - store
timestamptzon the server - order by
updated_atfor sync, notclient_updated_at
7.4 Offline creates and “missing rows”#
If you create a row offline and show it immediately, ensure it has a UUID and is inserted into local DB first. When syncing, insert it to Supabase using the same ID to avoid duplicates.
7.5 Excessive bandwidth#
You can easily burn through mobile data if you:
- poll too frequently
- refetch full lists after every change
- subscribe broadly
Use:
- incremental pulls with
updated_at - realtime as patching, not refetch triggers
- pagination and server-side filters
# 8) Testing Checklist for Auth, RLS, Realtime, and Offline#
You should test these scenarios before release:
| Scenario | Expected behavior | How to test |
|---|---|---|
| Token refresh | User stays logged in | Wait 1 hour, background app, reopen |
| RLS bypass attempt | Forbidden | Call REST with another user id |
| Offline edit | UI updates instantly, queued sync | Airplane mode then edit |
| Conflict | Deterministic resolution | Edit same row on two devices |
| Realtime update | Other device receives patch | Two devices on same team |
| Logout | Local data handled safely | Logout with offline mode too |
ℹ️ Note: RLS tests should include direct API calls using the anon key, not only in-app flows. The attacker model is “someone calls your Supabase endpoints directly.”
# Key Takeaways#
- Treat
Flutter Supabase auth realtime offline syncas four separate concerns: auth, RLS, realtime, and sync, then integrate them through repositories and a local DB. - Enforce permissions in PostgreSQL with RLS, default
user_idtoauth.uid(), and never rely on client-side authorization. - Use realtime narrowly with filters, subscribe only while screens are active, and apply events to a local cache instead of rebuilding UI from network payloads.
- Implement offline-first UX using a local database plus an outbox queue, then sync with incremental pulls using
updated_atcheckpoints. - Add version-based optimistic concurrency to detect conflicts and resolve them with a defined strategy, not guesswork.
- Build debugging tools early: outbox inspector, sync logs, and auth state visibility to reduce production incident time.
# Conclusion#
Flutter and Supabase can be a strong production combo when you design for untrusted clients, intermittent connectivity, and multi-device concurrency from day one. Start by locking down RLS, then build a local-first repository layer with an outbox and incremental sync, and finally layer realtime on top as a cache invalidation and patch mechanism.
If you want Samioda to review your Supabase RLS policies, implement an offline-first sync layer, or ship a production-ready Flutter architecture, contact us via our site and share your current schema and user flows.
FAQ
More in Mobile Development
All →Scaling Flutter with Modularization: Monorepo Setup with Melos, Shared Packages, and Clean Boundaries
A practical guide to Flutter monorepo Melos modularization: when to split into packages, how to structure shared code, enforce boundaries, and run CI and tests efficiently across a growing codebase.
Flutter vs Native iOS/Android in 2026: Cost, Performance, and Time-to-Market Tradeoffs
A practical, numbers-driven comparison of Flutter vs native iOS and Android for 2026 — including cost model, performance reality, maintenance impact, and a decision framework for MVPs, high-performance UI, heavy platform APIs, and regulated apps.
Flutter Deep Linking Guide for 2026: Universal Links, Android App Links, and Reliable In-App Routing
A practical, production-ready guide to Flutter deep linking: Universal Links, Android App Links, go_router route handling, deferred deep links, attribution basics, and a troubleshooting checklist.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Flutter + Firebase: Complete Tutorial for 2026 (Auth, Firestore, Functions, Deploy)
A step-by-step flutter firebase tutorial for 2026: set up Firebase, add authentication, build Firestore CRUD, write Cloud Functions, and deploy a production-ready app.
Flutter vs Native iOS/Android in 2026: Cost, Performance, and Time-to-Market Tradeoffs
A practical, numbers-driven comparison of Flutter vs native iOS and Android for 2026 — including cost model, performance reality, maintenance impact, and a decision framework for MVPs, high-performance UI, heavy platform APIs, and regulated apps.
Flutter Deep Linking Guide for 2026: Universal Links, Android App Links, and Reliable In-App Routing
A practical, production-ready guide to Flutter deep linking: Universal Links, Android App Links, go_router route handling, deferred deep links, attribution basics, and a troubleshooting checklist.