Skip to content

Local Supabase or a hosted branch - pick per environment, not per team

The CLI stack and Supabase branching solve different problems. Use local for the inner loop, a branch for preview deploys, and never share one dev project.

Supabase5 min readships at docs/solutions/supabase/local-dev-vs-supabase-branches.md

Tags: supabase · local-development · branching · preview-environments · cli

Three people share one Supabase project called myapp-dev. On Tuesday one of them renames a column. The other two get column "full_name" does not exist in the middle of unrelated work. Someone runs a destructive migration to unblock themselves and wipes the fixtures everybody was relying on. By Friday the team has a rule: "tell the channel before you touch dev".

That rule is the smell. A shared development database is a mutex with no lock, and it gets worse as the team grows. Supabase gives you two ways out, and they are not alternatives - they cover different parts of the workflow.

The wrong way: one hosted project everyone points at

.env.local  (identical on three laptops)
NEXT_PUBLIC_SUPABASE_URL=https://abcdefgh.supabase.co
DATABASE_URL=postgresql://postgres.abcdefgh:...@aws-0-eu-west-1.pooler.supabase.com:6543/postgres

What breaks:

  • Migrations are applied out of order. Whoever pushes first wins; the second person's migration assumes a schema that already moved.
  • You cannot test a destructive change. Dropping a column to see what breaks breaks it for everyone.
  • Seed data rots. After a month, dev holds a mixture of fixtures, half-finished test rows and one person's manual debugging.
  • Nothing is reproducible. A bug that only appears on one laptop is untraceable, because the database is not part of the repository.

The right way, part one: local for the inner loop

The Supabase CLI runs the whole stack - Postgres, PostgREST, GoTrue, Realtime, Storage, Studio - in Docker on your machine.

bun run db:start     # supabase start
bun run db:reset     # replay supabase/migrations, seed, then db:migrate
bun run db:types     # regenerate src/db/types.generated.ts

.env.local points at the containers:

NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=<printed by supabase start>
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres

The local anon and service-role keys are fixed demo JWTs signed with a public secret. They are the same on every machine, they are safe to commit to .env.example, and they are worthless outside localhost.

What you get is a database that is part of the repo. bun run db:reset takes a few seconds and returns you to a known state: every migration replayed from empty, then every supabase/seed/*.sql, then your ORM's own migrations. That single command is also your migration test - a migration that works as a one-off statement but fails on a clean replay is broken, and you find out in the loop rather than during a deploy.

Costs to be honest about: Docker has to be running, the images are roughly a gigabyte, and the first supabase start after a version bump is slow. Pin major_version in supabase/config.toml to whatever the hosted project runs - Project Settings, Infrastructure - or you will develop against Postgres 15 and deploy to 17. bun run verify compares the pin against the version it actually connected to and fails when they disagree, so this is one mistake you do not have to remember.

The right way, part two: branches for preview deploys

Local stops working the moment something outside your laptop needs the database. A Vercel preview deployment cannot reach 127.0.0.1:54321. Neither can a designer clicking your PR link, nor a Playwright run in a container, nor a webhook from Stripe.

That is what Supabase branching is for. A branch is a real, separate Supabase project - its own URL, its own keys, its own Postgres - created from your repository's migrations.

bun run db:link --project-ref <production-ref>
bunx supabase branches create preview-checkout --persistent
bunx supabase branches list
bunx supabase branches get preview-checkout   # prints the branch URL and keys

Wire those values into the preview environment of your host. On Vercel, set them as Preview-scoped environment variables:

bunx vercel env add NEXT_PUBLIC_SUPABASE_URL preview
bunx vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY preview
bunx vercel env add DATABASE_URL preview

Branch creation runs supabase/migrations and supabase/seed/*.sql against a fresh database, so a branch is reproducible in the same way a local reset is - with one gap. Supabase knows nothing about your ORM's migration history, so a branch comes up with the Supabase half of the schema and none of the ORM half; run the ORM's deploy command against the branch's connection string before you point a preview at it. Two other things to know before you rely on branches: they are a paid feature and are billed like small projects, and creating one takes minutes rather than seconds because a real Postgres instance is being provisioned. Delete branches you are not using - bunx supabase branches delete <name> - or you will pay for a dozen abandoned preview databases.

What about staging?

A long-lived staging project sits between the two. It is worth having when you need data that survives - a support team clicking through, a load test, an integration partner with a fixed callback URL. Treat it exactly like production: migrations arrive through supabase db push, nobody edits it in the dashboard, and it never holds a copy of real user data unless it has been anonymised.

The decision table

SituationUse
Writing a feature, iterating on schemaLocal CLI
Testing a destructive migrationLocal CLI, then db:reset
Vercel preview deployment on a PRBranch, Preview-scoped env vars
External webhook needs to reach the appBranch or staging
Demo for a stakeholderStaging
Anything a customer touchesProduction only

The rule that actually prevents the Tuesday incident

Nobody points a laptop at a shared hosted project. If a person needs a database, it is either in Docker on their machine or it is a branch that belongs to their pull request. supabase/migrations and supabase/seed/ are the only mechanism by which schema and fixtures travel between them, which means the database is reviewable in a diff like everything else.