All articlesSupabasePostgreSQLArchitecture

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.

Filipi RafaelFilipi Rafael3 min read

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.users row 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:

  1. 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.
  2. 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.
  3. 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.

Filipi Rafael

Filipi Rafael

Mobile software engineer, React Native

frafael.co

Keep reading