One Supabase project for every app I ship
How a schema per app, row-level security on every table and prefixed edge functions let a solo developer add a new backend in zero minutes.
Every new app I ship gets a backend in zero minutes. Not because I am fast at setting up Postgres, but because I refuse to set it up again. There is one Supabase project for the whole portfolio, and each app lives inside it as a schema.
The rule
- One Supabase project, forever.
- Each app gets its own schema, named
app_<slug>. - Every table that the client can reach has row-level security enabled, with policies written before the first row exists.
- Edge functions are prefixed with the app slug,
meowtive-sync, so the dashboard stays readable at twenty apps. - Auth is shared. A person who signs in with Apple in one app is the same
auth.usersrow in another, which is exactly what you want when the portfolio has a brand.
Why not a project per app
The obvious objection is isolation. A bug in one app's SQL cannot touch another app's data if they are separate projects. True, but that is what schemas and RLS are for, and a project per app has costs that compound:
- Every project needs its own auth configuration, redirect URLs, OAuth client, SMTP and storage buckets. I did that twice and lost two evenings to the same Google consent screen.
- Free-tier projects pause after a week of inactivity. A portfolio of small apps is a portfolio of quiet databases; one paid project never pauses.
- Cross-app questions become impossible. "Which of my users have two of my apps installed" is a single query when the users table is shared.
What a schema per app looks like
The migration for a new app is boring on purpose:
create schema if not exists app_meowtive;
create table app_meowtive.habits (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
title text not null,
created_at timestamptz not null default now()
);
alter table app_meowtive.habits enable row level security;
create policy "owner reads" on app_meowtive.habits
for select using (auth.uid() = user_id);
create policy "owner writes" on app_meowtive.habits
for insert with check (auth.uid() = user_id);
Two things matter here. on delete cascade from auth.users is what makes
the in-app "Delete account" button honest: removing the auth user removes
everything the app knew about them, in every schema, in one statement. And
the policies compare against auth.uid(), never against a column the client
could send. The client never gets to say who it is.
Exposing the schema
Supabase only exposes public through the API by default. Each app schema is
added to the exposed list once, and the client selects it explicitly:
const { data } = await supabase
.schema("app_meowtive")
.from("habits")
.select("id, title")
.order("created_at");
Types are generated for the whole project, so Tables<"app_meowtive", "habits">
exists the moment the migration runs. The shared package @frafael/supabase
holds the client and those types; no app forks it.
The part people skip
RLS is not a checkbox, it is the backend. If a table is exposed and has RLS enabled but no policy, nobody can read it, which is the correct failure. If a table is exposed and RLS is off, everybody can read it, which is the failure you find out about from a stranger. So the checklist for every new table is the same three lines: enable RLS, write the owner policy, test it with a second account before writing any UI.
What it costs me
One mental model. One set of dashboards. One place where auth is configured, one Google OAuth client reused forever, one privacy policy that can truthfully say where the data lives. The next app starts at screen one, not at infrastructure.
