Skip to content
Tech Stack16 August 2026 · 10 min readUpdated 30 September 2026

Flutter + Supabase in 2026: The Production Pattern

The client talks straight to Postgres only where a policy fully describes the rule. Where that line sits in a Flutter app, and the four places production breaks when it moves.

Flutter + Supabase in 2026: The Production Pattern

Before you write another screen, list every place your Flutter app talks to Supabase and mark each one as either a row a policy can fully describe or a workflow that needs server code. Most Flutter and Supabase tutorials stop when a query returns rows. Production starts a few problems later: a session that ends when the user didn't expect it, a policy that scans a whole table, a realtime subscription that stops scaling once enough devices join, and an app opened with no signal.

I should say up front what I run on this stack. BookBed, the property management SaaS I built solo, is Flutter across six platforms on Firebase and Stripe. It does not use Supabase. Pizzeria Bestek is React on Supabase, not Flutter. So this post isn't a report from a Flutter and Supabase app of mine. It's a production pattern assembled from the documentation, checked against the primary sources on 30 September 2026, with my recommendations labelled as recommendations. Where the call is close, I say so.

What does a production Flutter and Supabase app look like?

A sheet of tracing paper laid over a hand-drawn two-column list on a warm beige desk, a single strip of masking tape running down the middle as the divider, one corner of the tracing paper curled up and lifting away from the tape

The client talks straight to Postgres only where a row-level security policy fully describes the rule, and everything else goes through server-side code. That line is the whole architecture.

It sounds obvious until you try to draw it. The tempting version of the stack is a Flutter app with no backend: the SDK is right there, the tables are right there, why add a hop? Because some rules aren't row predicates. "This user may read this booking" is a predicate. "This user may refund this booking, but only within 48 hours, and only if the payment isn't disputed" is a workflow, and workflows belong where you can log them, retry them and change them without shipping a new build to two app stores. The 48 hours in that example is an illustration, not a real rule.

OperationClient-direct through RLSEdge Function or server
Read a user's own rowsYes, one indexed equality checkNo
Insert a record the user ownsYesNo
Payments, refunds, webhook handlingNoYes, the secret key stays server-side
Admin or moderation actionsNoYes
Multi-step transactionsNoYes, one RPC in one transaction
Anything an attacker could replayNoYes

Draw that table for your own app before you build. Rows on the left get a policy and an index. Rows on the right get a function, and the Flutter side never sees a secret key.

Your RLS policies are your query plan

A folded architectural blueprint spread on a concrete desk under hard window light, a translucent plastic ruler laid across one line of the linework casting a hard-edged shadow, a deep fold crease keeping the paper from lying flat

Treat row-level security as part of query performance, not only a security feature. Supabase's RLS performance guide says Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. On a phone, every list view is a round trip, and a table small enough to benchmark on your laptop hides the problem.

Supabase's RLS guide gives three rules to apply first, and none of them touch your Dart:

  1. Index the columns your policies filter on. The guide says an unindexed filter column turns a read into a sequential scan, and that a column counts as indexed only when it comes first in a btree index. A membership table keyed on (team_id, user_id) has no index on user_id.
  2. Call functions with select. Write (select auth.uid()) = user_id instead of auth.uid() = user_id. The guide says wrapping the function makes Postgres run an initPlan that caches the result per statement instead of calling the function on each row. It only works if the result doesn't depend on row data.
  3. Name the role with to authenticated. The guide says this stops the policy running for anonymous users, since execution ends at the role check.

The current Supabase pages don't publish benchmark timings for these edits, so I won't quote any. Measure your own instead. The performance guide's method is to run the query with RLS enabled, then again with it disabled in a non-production environment, and compare. If the times are similar, the query is the problem, not the policy. It also suggests adding an explicit filter such as .eq('user_id', userId) in the client query even though it duplicates the policy, because Postgres can use it to build a better plan.

There's one failure that looks like a Supabase bug and isn't. You have INSERT and UPDATE policies, you call upsert() from Flutter, and Postgres reports a row-level security violation. The PostgreSQL CREATE POLICY documentation explains it: an INSERT with ON CONFLICT DO UPDATE requires SELECT permissions on the relation, and the proposed rows are checked against the SELECT policies. Add a SELECT policy, or move the operation into an RPC.

Storage uploads and rows are two separate writes

Uploading a file to a bucket doesn't create a row in your database, and a row doesn't prove the file arrived. My recommendation is to write the record after the upload resolves, not alongside it. Otherwise a list view can render a thumbnail for an object that doesn't exist, and the bug report reads "broken images" when the real bug is ordering.

Offline is an architecture decision

An offline-first Flutter app reads from a local database and syncs in the background, which is a decision about your data layer, not a package you add in month five. Supabase's own offline-first Flutter guide, published in October 2024, introduces Brick as a data manager that handles querying and uploading between Supabase and a local cache such as SQLite. It opens by noting that people use phones on subways, airplanes and sub-3G connections.

Read the caveats in that post before committing. It says Brick's subscription stream doesn't use Supabase's channels by default, so if Supabase updates, your app isn't notified, and it calls that opt-in feature under active development. That's a reasonable trade, but it's a trade, and finding out after you've modelled forty tables is expensive.

Here's my recommendation for most apps: you probably don't need full offline sync. You need reads served from a cache so a cold launch isn't a spinner, and writes that fail visibly instead of silently. That's a much smaller project.

Rotate your API keys before the deadline

Supabase is deprecating the legacy anon and service_role keys by the end of 2026, in favour of publishable (sb_publishable_) and secret (sb_secret_) keys. Its migration guide says both key types work simultaneously, so you can swap clients one at a time and deactivate the legacy keys only after nothing depends on them. It lists callers that are easy to miss, and the first is mobile or desktop app versions already in users' hands. Old installs are the mobile-specific risk.

On the Flutter side, the supabase_flutter package page shows Supabase.initialize taking a publishableKey, and listed version 2.18.0 on 30 September 2026. The half that's easy to miss is every service_role key sitting in an Edge Function, a CI secret or a quick script. Find those while both key types still work.

Why is my Supabase realtime subscription not scaling?

A small tarnished brass desk bell standing in fine pale dust, concentric rings pushed outward from its base like a ripple, a fingerprint smudge visible on the polished dome, with a coiled cable and blank sticky note blurred behind it

Usually because it uses Postgres Changes, which authorizes every event against each subscriber, instead of Broadcast, which Supabase recommends for scalability and security.

Supabase's Postgres Changes guide is explicit: when you make one change to a table with 100 subscribed users, Realtime performs 100 authorization checks, so throughput scales with the number of subscribers, not the write rate. Changes are also processed on a single thread to preserve order. The guide says that above roughly 3,000 concurrent subscribers on the same changes, you should use Broadcast. The subscribing-to-database-changes guide calls Broadcast the recommended method for scalability and security, and describes a database trigger that calls realtime.broadcast_changes().

Postgres ChangesBroadcast from the database
Who authorizesRealtime, per subscriber, per eventBroadcast authorization RLS policies on a private channel
Payload controlThe row, subject to a size limitWhat the trigger sends
Scaling profileScales with subscribers, single-threadedSent once, fanned out to all subscribers
Setup costOne subscription in the clientA trigger plus a policy on realtime.messages

Know the quotas before you design around them. The Realtime limits page lists concurrent connections of 200 on Free, 500 on Pro, and 10,000 on Pro without a spend cap and on Team, with messages per second of 100, 500 and 2,500 for the same plans. Postgres change payloads are limited to 1,024 KB, and past that limit the new and old records include only fields whose value is 64 bytes or smaller, so a wide row degrades into a partial one instead of failing loudly.

Mobile adds its own cost. The supabase_flutter README says Supabase disconnects the realtime socket when the app is paused and reconnects it, rejoining every joined channel, on resume, and that every reconnect costs a new socket and a fresh authorization of each channel. It documents RealtimeLifecycleOptions to keep the socket open through short pauses. My inference is that a phone that keeps switching apps generates channel joins that a browser tab doesn't, so budget your joins per second accordingly.

Why does my Flutter app log users out with Supabase?

Because the session ended for a documented reason, or because your routing acted before the auth state had spoken. Check the documented reasons first, then check your startup order.

Supabase's user sessions documentation lists when a session terminates: the user signs out, changes their password or performs a security-sensitive action, times out from inactivity, reaches its maximum lifetime, or signs in on another device, depending on configuration. The same page says a refresh token can be exchanged only once, and describes a mobile-shaped failure: if the response to a refresh isn't received, the client comes back online with a token already used, which can terminate the session unexpectedly. Reuse within a default 10-second interval is tolerated, and reuse of the parent of the active token returns the active one.

Then your startup order. The package persists the session for you: the pub.dev README says it uses shared_preferences by default, with a LocalStorage interface you can replace. I haven't reproduced a race between Supabase.initialize and a first read of currentSession in a Flutter app, so I won't claim one. My recommendation is defensive:

  • Drive routing from onAuthStateChange, which the API reference lists with an initialSession event, instead of a one-shot read of currentSession.
  • Pass an onError handler to that stream. The reference says network errors, such as an offline token refresh, are emitted as stream errors, and without a handler Dart rethrows them as unhandled zone exceptions that crash the app.
  • Show a real loading state between "initialize returned" and "auth state arrived", not a splash you dismiss on a timer.
  • Let exactly one piece of code own refreshing, so two widgets never race to use the same single-use token.
  • Register your redirect scheme on every platform you ship. The README says supabase_flutter uses app_links internally for deep links, and desktop targets are the ones people forget until a magic link opens nothing.

Sign-out deserves the same care in reverse. Dispose account-scoped channels and clear cached rows, or the next person on the device can see a flash of someone else's data before the first fetch lands.

When should you pick Firebase instead of Supabase?

Pick Firebase when your data is document-shaped and you want offline persistence without adding a sync layer, and Supabase when the data is relational and someone on the team will own SQL policies. BookBed's case study documents Firebase and Stripe, and I'm not claiming a benchmark against Supabase.

SignalLeans FirebaseLeans Supabase
Data shapeDocumentsRelational tables, joins, constraints
Access rulesFirestore security rulesSQL policies you write and tune
Offline needsFirestore offline persistence, on by default on Android and Apple platforms, off by default on webNeeds Brick, another sync layer, or your own cache
Team's SQL depthLowComfortable reading and writing policies

The offline row comes from the Firestore offline data documentation. The access-rules row is my summary: Supabase gives you real SQL, and keeping the policy layer fast becomes your job. If you want the wiring notes rather than the decision, they're in the Flutter and Supabase stack guide.

Time zones are backend-independent. BookBed's case study describes an early overbooking check that normalized dates with setUTCHours(0,0,0,0) and toISOString() and gave the wrong date for bookings stored late in the evening UTC, which is already the next day in Zagreb. Postgres won't save you from that class of bug either. Its date and time documentation says timestamptz values are stored as UTC and converted to the session time zone on output, so formatting in the wrong zone still shows the wrong-looking day.

Pick one screen and time its policy

Open your slowest list view, take the policy behind it, and apply the three edits above: index the filtered column, wrap the auth call in select, name the role. Run the query with RLS on and off before and after. If the number doesn't move, the round trip or an image is your problem, not a policy, and you've ruled out the expensive suspect quickly. Which one is it in your app?

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do Postgres RLS policies affect query speed from a Flutter client?

A policy is evaluated against each candidate row, so a poorly written one dominates the latency your Flutter app sees. Supabase's guide says the cost scales with the rows a query scans and gives three rules to apply first: index the columns your policies filter on, call functions like auth.uid() through select so Postgres caches the result per statement, and name the role with to authenticated so anonymous users skip the policy. To check whether RLS is the bottleneck, run the query with RLS on and off in a non-production environment and compare the times.

How do I do realtime in Flutter with Supabase without hitting scaling limits?

Subscribe to Broadcast events sent by a database trigger instead of Postgres Changes on the table. Supabase calls Broadcast the recommended method for scalability and security. Its Postgres Changes guide explains that each event is authorized against each subscriber, so one change with 100 subscribers means 100 checks, and it suggests Broadcast above roughly 3,000 concurrent subscribers. Write a trigger that calls realtime.broadcast_changes(), add a policy on realtime.messages, and listen from a Flutter channel. Also check the plan limits, since supabase_flutter rejoins channels each time the app resumes from a pause.

Why does my Flutter app log users out with Supabase?

Usually because the session ended for a documented reason, or because routing acted before the auth state arrived. Supabase's session documentation lists sign-out, password change, inactivity timeout, maximum lifetime and signing in on another device as ways a session ends, depending on configuration. A refresh token can also only be exchanged once, so a lost refresh response can terminate the session on reconnect. My recommendation is to drive routing from onAuthStateChange with an onError handler, and let exactly one piece of code own refreshing.

Is Supabase a good backend for a mobile app, or should I use Firebase?

Supabase fits when your data is relational, you want joins and constraints, and someone on the team will own SQL policies. Firebase fits when the data is document-shaped and you want offline persistence without adding a sync layer, since Firestore's offline persistence is on by default on Android and Apple platforms. The real difference is who keeps the data layer fast: Postgres policies need indexes and tuning, and that becomes your job. BookBed's case study documents Firebase and Stripe, and that isn't a benchmark claim against Supabase.

Which package do I need for Supabase in Flutter, and how do I initialize it?

Use supabase_flutter, which wraps the Dart client with Flutter session persistence and deep link handling. Call WidgetsFlutterBinding.ensureInitialized() in main(), then await Supabase.initialize with your project URL and a publishableKey, and use the client through Supabase.instance.client. Its README says sessions persist through shared_preferences by default, with a LocalStorage interface you can replace, and that it uses app_links internally for deep links. Register your redirect scheme on every platform you ship, including desktop, or magic links and OAuth callbacks will open nothing.