Database
Next.js boilerplate with Supabase
Managed Postgres with row level security, realtime and a local stack in Docker.
Postgres with auth, storage and realtime attached, plus a local stack you can run from the CLI. Pick it when you want one vendor for the database and the services around it.
What Supabase adds to the agent layer: 2 rules · 2 skills · 5 solution docs · 1 MCP server
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Supabase?
Pick it if
Teams that want one Postgres provider to also cover auth, storage and realtime. Also teams who want to run the real stack locally, not against a shared dev database.
Watch out for
- A full Postgres, not a subset: extensions, triggers, functions and logical replication all work.
- Row level security is the security model. Going through the service role key everywhere throws away most of what you are paying for.
Show 4 moreShow fewer
- The local CLI boots Postgres, PostgREST, GoTrue, Realtime and Storage in Docker, so the dev loop needs Docker running.
- Branching exists but is a paid feature and slower to spin up than a local reset. Most teams use local for the inner loop and branches for previews.
- Two connection strings: direct on port 5432 and the pooler on 6543. Pick the wrong one for a serverless runtime and you run out of connections in production.
- Auth, storage and realtime are optional. Using Supabase purely as Postgres is supported and common.
What it costs
Free: 2 active projects, 500 MB database each, paused after a week idle. Pro is $25/month per organisation with $10 of compute credit, 8 GB disk per project, 7 days of daily backups and no pausing. Extra projects add compute cost.
Prices change. Check with Supabase before you commit.
registry/tested.yaml
Tested with Supabase
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Supabase adds to the repo
Read straight from the supabase manifest, so it is exactly what lands in your repo.
Environment variables
DATABASE_URLRequired
Postgres connection string used by the ORM. Use the transaction pooler on port 6543 for serverless runtimes.
- Where to get it
- Supabase dashboard -> Project Settings -> Database -> Connection string -> Transaction pooler
- Placeholder
- postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres
DIRECT_URLOptional
Direct connection on port 5432. Only used by migrations and introspection, never by the app at runtime.
- Where to get it
- Supabase dashboard -> Project Settings -> Database -> Connection string -> Direct connection
- Placeholder
- postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres
NEXT_PUBLIC_SUPABASE_URLRequiredPublic, reaches the browser
Project REST/realtime endpoint. Safe in the browser bundle.
- Where to get it
- Supabase dashboard -> Project Settings -> API -> Project URL
- Placeholder
- https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEYRequiredPublic, reaches the browser
Anonymous publishable key. Only ever as strong as your row level security policies.
- Where to get it
- Supabase dashboard -> Project Settings -> API -> Project API keys -> anon public
- Placeholder
- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.anon-key
SUPABASE_SERVICE_ROLE_KEYOptional
Bypasses row level security. Server-only, never imported from a client component, never prefixed with NEXT_PUBLIC_. Optional for a repo that uses Supabase purely as Postgres, because nothing here reads it until you call supabaseAdmin(); the Supabase Auth and Supabase Storage batteries both do, so selecting either makes
bun run verifyrequire it regardless of the "optional" marker on this line.- Where to get it
- Supabase dashboard -> Project Settings -> API -> Project API keys -> service_role
- Placeholder
- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.service-role-key
SUPABASE_ACCESS_TOKENOptional
Personal access token for the Supabase CLI and the bundled MCP server.
supabase linkandsupabase db pushprompt for it if it is unset; the MCP server does not - it starts anyway and every call comes back 401. The running app never reads it.- Where to get it
- https://supabase.com/dashboard/account/tokens
- Placeholder
- sbp_0000000000000000000000000000000000000000
Dependencies
- @supabase/supabase-js^2.117.0
- postgres^3.4.4
- server-only^0.0.1
- supabase^2.2.1dev
Scripts
- bun run db:deploy
bunx supabase db push
- bun run db:link
bunx supabase link
- bun run db:reset
bunx supabase db reset && bun run db:migrate
- bun run db:start
bunx supabase start
- bun run db:stop
bunx supabase stop
- bun run db:types
bunx supabase gen types typescript --local > src/db/types.generated.ts
MCP server
supabase
- Command
- npx -y @supabase/mcp-server-supabase@latest --read-only
- Environment
- SUPABASE_ACCESS_TOKEN
Files it writes
8 files, at these exact paths.
src/2 files
db/2 files
- client.ts
- url.ts
supabase/2 files
seed/1 file
- 00_conventions.sql
- config.toml
variants/4 files
browser-anon/1 file
src/1 file
db/1 file
- supabase-browser.ts
browser-shared-session/1 file
src/1 file
db/1 file
- supabase-browser.ts
starter-profiles/2 files
supabase/2 files
migrations/1 file
- 20250101000000_init_profiles.sql
seed/1 file
- 10_profiles.sql
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot db-client
- @slot env-required
- @slot legal-processors
- @slot verify-checks
The differentiator
What Supabase teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (2)
Loaded when the agent opens a matching file.
Row level security is on by default and the service role is a last resort
Loads onsrc/db/**.claude/rules/supabase-db-access.md
src/db/ holds the only database credentials in my-app. Four clients live here and they are not interchangeable.
| Client | Credential | Respects RLS | Use for |
|---|---|---|---|
sql (./client) | Postgres role, DATABASE_URL | no | ORM queries, joins, aggregates, anything transactional |
supabaseAsUser(token) | anon key + caller's JWT | yes | acting on behalf of a signed-in user from server code |
supabaseAdmin() | service role key | no | Storage, Auth admin, Realtime broadcast |
createBrowserSupabase() (./supabase-browser) | anon key, plus the session cookie when Supabase Auth is installed | yes | realtime subscriptions and browser uploads |
supabaseAsUser(token) needs a token PostgREST will accept: a Supabase (GoTrue)
access token, or a third-party JWT from a provider registered under
Authentication -> Third-party Auth. With the Supabase Auth battery that token
comes from getSession() in @/lib/auth/server. With Clerk or Better Auth
there is no such token until you configure the integration, so server code that
needs the caller's permissions uses sql with an explicit owner filter instead.
Rules
src/db/client.tsis server code, but it is deliberately not markedserver-only:@/dbis loaded from the terminal by the repo's own commands, andserver-onlyis a barethrowoutside Next.js. What keeps the credentials out of the browser is that the modules consuming them are server-only and that the browser client is a separate file. Never import./client, or anything that imports it, from a"use client"component or frommiddleware.ts. Browser code uses./supabase-browser.SUPABASE_SERVICE_ROLE_KEYnever appears in a file undersrc/appthat is not a route handler or a server action, never gets aNEXT_PUBLIC_prefix, and never gets passed to a client component as a prop. It bypasses every policy you wrote.- Because
sqlalso bypasses RLS, every query built from request input must filter by the authenticated user id explicitly.where user_id = ${session.user.id}is not optional just because the ORM feels safe. - Interpolate values through the driver's tagged template
(
sql`select * from t where id = ${id}`). Never build SQL by string concatenation.sql.unsafe()requires a comment justifying it. - Do not add a second
postgres()pool. Importsqlfrom./client; it is cached onglobalThisso hot reload does not exhaust the connection limit. prepare: falseandmax: 1on the transaction pooler (port 6543) are load bearing. Do not "optimise" them away - see the pooler solution doc.
Generated types
src/db/types.generated.ts is produced by bun run db:types against the local
database, and committed. It does not exist until you have run that command once,
so run it after the first bun run db:reset and again in every commit that
changes SQL. Never hand-edit it: if a type looks wrong, the migration is wrong.
Application-level types that are not a mirror of a table belong in
src/db/types.ts or next to the feature that owns them.
Schema changes go through supabase/migrations, never the dashboard
Loads onsupabase/**.claude/rules/supabase-migrations.md
The database schema of this project is defined by the SQL files in
supabase/migrations, applied in filename order, plus whatever your ORM's
own migrations create. Two histories, two commands, one database - and nothing
else is a source of truth for either.
Which one owns a table is decided by who created it: tables you declare in the
ORM's schema file belong to the ORM's history, everything else (extensions,
policies, functions, triggers, grants, and any table an auth battery brought)
belongs to supabase/migrations. Keep it that way; a table created in both
histories is a db:reset that fails on the second run.
Non-negotiable
- Never edit a migration that has already been applied to a shared or hosted database. Migrations run forward exactly once. Fix a mistake with a new migration that alters the previous state.
- Never ask the user to "just run this in the SQL editor". A change made in the
dashboard exists on exactly one machine and disappears on the next
bun run db:reset. If the user has already done it, capture it withbunx supabase db diff -f <name>and commit the generated file. - Create migrations with
bunx supabase migration new <snake_case_name>. The CLI assigns the timestamp prefix; do not invent one by hand. - Every
create tablein thepublicschema ends withalter table <name> enable row level security;in the same migration, plus at least one policy. See the/add-rls-policyskill and thesupabase-db-accessrule. - The ORM's generator does not do that - it has never heard of RLS - so a table
declared in the ORM schema gets its
enable row level securityfrom a hand-written migration in the ORM's history (drizzle-kit generate --custom, orprisma migrate dev --create-only), not from one here.db:resetreplayssupabase/migrationsbefore the ORM's, so a statement here about an ORM-owned table fails on "relation does not exist".bun run verifynames every table still missing RLS after a reset. drop table,drop columnand destructivealter ... typeneed an explicit sentence in the migration comment describing what happens to existing rows, and the user's confirmation before you write them.
Workflow for any schema change
bunx supabase migration new add_widgets # creates supabase/migrations/<ts>_add_widgets.sql
# edit the file: DDL, then RLS, then policies, then indexes
bun run db:reset # replays supabase/migrations + seeds, then db:migrate
bun run db:types # regenerates src/db/types.generated.ts
bun run verify # names any table still missing RLS
bun run db:reset is the test: if the full replay from an empty database fails,
the migration is broken no matter how well it worked as a one-off statement. It
re-runs the ORM's migrations afterwards because supabase db reset drops the
tables the ORM created along with everything else - the two halves are one
operation, not a choice.
Getting it to the hosted project is bun run db:link once, then
bunx supabase db push --dry-run and bun run db:deploy. Seeds are never
pushed.
Committing
src/db/types.generated.ts is generated but committed, in the same commit as
the migration that changed it. A pull request that changes SQL without changing
the generated types is either incomplete or the author skipped db:types.
Local artefacts (supabase/.branches, supabase/.temp) are gitignored. Everything
else under supabase/ belongs in version control, including config.toml and
every file in supabase/seed/.
Functions and triggers
Postgres functions in a migration must pin their search path
(set search_path = '') and fully qualify every identifier. A security definer
function without a pinned search path is a privilege escalation waiting for a
schema-shadowing attack.
Skills (2)
Invoked by name.
- /add-rls-policy
Add or fix row level security policies on a Supabase table, with a migration, a policy test and regenerated types.
.claude/skills/add-rls-policy/SKILL.md
- /local-supabase
Boot, reset, inspect and troubleshoot the local Supabase stack, and pull schema down from a hosted project.
.claude/skills/local-supabase/SKILL.md
Solution docs (5)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Server code should stop using the anon key, and must not reach for the service role insteadThe anon key is a public identifier, not a credential. Server work needs either the caller's JWT or a deliberate, audited service-role call. Here is how to tell which.docs/solutions/supabase/beyond-the-anon-key-on-the-server.md
- Supabase gives you three connection strings: pick the right one or production falls overDirect on 5432, session pooler on 5432, transaction pooler on 6543. Which one serverless needs, why prepared statements break, and what to run migrations on.docs/solutions/supabase/direct-vs-pooler-connection-strings.md
- Stopping Supabase generated types from drifting out of the schemaGenerated database types are only true at the moment they were generated. Commit them, regenerate them in the same commit as the migration, and check them in verify.docs/solutions/supabase/generated-types-drift.md
- Local Supabase or a hosted branch - pick per environment, not per teamThe 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.docs/solutions/supabase/local-dev-vs-supabase-branches.md
- Row level security when an ORM is doing the queryingYour ORM connects as the postgres superuser, so RLS never runs. Here is how to keep policies meaningful without giving up typed queries.docs/solutions/supabase/rls-with-an-orm-in-front.md
How it fits
What Supabase needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
- An ORM battery. The resolver adds the default one for you and tells you why.
Pairs well with
Cannot be combined with
Compared with the alternatives
Build a repo with Supabase
Free and MIT. The builder opens with Supabase picked. You download the zip right away, and we email you the link too.
Presets
Presets that already include Supabase
A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.