One backend for the app and the admin
The Flutter (or Swift) client and the React admin share one Supabase project. Auth, RLS, storage. A second database for the dashboard is a mistake.

A mobile product that needs operators is not an app plus a later dashboard. It is one Supabase mobile backend and two clients. The phone is Flutter, or Swift when the product belongs on Apple. The desk is React. Same project.
The admin post is the control surface. This note is the data layer. Same triangle. Different job.
One project, two clients
Backend for a mobile app, here, means one Postgres schema, one Auth, one Storage. Not a Node API for the phone and a second database for the admin. Both clients use the publishable key and a user JWT. The schema is designed once.
Flutter is the default phone. Swift talks to the same project when the product is Apple’s. The admin is React either way. There is no sync job. There is one row.
A second database for the dashboard is a mistake
Two sources of truth. “Is this offer live?” becomes a sync job. Drift. Double writes. A night-time reconciliation nobody wanted. The merchant publishes; the shopper must see that row. The clinic confirms; the patient must see that row.
If you need a reporting replica later, that is a replica. It is not a second product database. The dashboard is a second face on the same data. It is not a second product.
The publishable key is supposed to be in the app
In 2026 Supabase names the keys publishable and secret. The legacy JWT names, anon and service_role, still work until they are disabled. Docs say they are deprecated by the end of 2026. We use the current names.
Flutter initialises with a URL and a publishableKey. Official stance: that pair will be in the app, and that is fine if row-level security is on. A mobile binary has no magic .env at runtime. Keys go in at build time. They are not committed. One sentence is enough. This is not a setup tutorial.
The secret key is the old service_role under a new name. Full access. Bypasses RLS. It belongs on a trusted server or an Edge Function. Not in the IPA. Not in the AAB. Not in the React bundle. Not in a screenshot. Not in a log. A secret used in a browser returns HTTP 401. That does not make a leaked secret safe.
Row-level security is the real gate
Hiding a button in Flutter or React is not access control. Anyone with the publishable key can call the Data API. A table in an exposed schema without RLS is readable and writable by that key. Table Editor turns RLS on by default. Raw SQL does not.
Postgres checks grants first — can this role run the verb — then policies — which rows. New public tables already grant select, insert, update, and delete to anon, authenticated, and service_role. Adding policies does not revoke those grants. Enable RLS with no policies and the API returns no rows on a publishable key. That is the closed default. It is not a finished product.
A Service Key with a user token attached runs as that user, not as the bypass. A wrong policy fails quietly: too open returns extra rows; too tight returns nothing and raises no error. UPDATE needs a matching SELECT policy. Cite the docs, then stop. We are not writing a policy cookbook.
Roles live in app_metadata or a membership table
user_metadata is editable by the signed-in user. Do not put authorization there. app_metadata is not. Roles and flags that the user must not change live there, or they live in a membership table.
A JWT is not always fresh. Change app_metadata and the old token still carries the old claims until refresh. One-product roles — shopper versus merchant staff — can live in server-owned claims. Organizations — many clinics, many merchants — need a membership table and a tenant key. That isolation is the multi-tenant note. Platform owner and organization admin are not the same login. Hiding a route is not a policy.
One row, two faces
T-CODE is one product. A merchant writes an offer and a branch. A shopper reads that offer on a map. Show or hide a branch, cap the codes, redeem. Same row. Two faces. Saudi. We do not claim a stack for it.
Naqrah.app is one appointment. A clinic organises the day. A patient books, reschedules, cancels. 100 points equal 1 OMR — already published. Oman. Same row. Two faces. Same policies on both JWTs. The face is membership, not a second table.
CareerFolio: RLS as data, on the web
CareerFolio is web. Say web. A finished performance year becomes a read-only archive. A reviewer gets a secure read-only share link. That is a state on the row plus a policy, not a greyed-out button. The work page already names Supabase and row-level security. Proof that the gate lives in Postgres even when there is no phone.
Storage is the same project
Offer images, clinic marks, portfolio attachments: same project, policies on storage.objects. A second bucket provider for the admin is the same mistake as a second database. Mention, then stop. We are not walking through an upload.
Schema first. Then the two clients. Credentials belong to the client at handover. If the dashboard is still “a later Firebase,” the product is not scoped. Write to us with the rows, not only with the screens.
Tell us what you’re building.
Write to us with the product you want built. You will get a considered reply from the people who would do the work.
Start a conversation