Skip to content

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 more
  • 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 verify require 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 link and supabase db push prompt 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.

    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.

ClientCredentialRespects RLSUse for
sql (./client)Postgres role, DATABASE_URLnoORM queries, joins, aggregates, anything transactional
supabaseAsUser(token)anon key + caller's JWTyesacting on behalf of a signed-in user from server code
supabaseAdmin()service role keynoStorage, Auth admin, Realtime broadcast
createBrowserSupabase() (./supabase-browser)anon key, plus the session cookie when Supabase Auth is installedyesrealtime 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.ts is server code, but it is deliberately not marked server-only: @/db is loaded from the terminal by the repo's own commands, and server-only is a bare throw outside 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 from middleware.ts. Browser code uses ./supabase-browser.
  • SUPABASE_SERVICE_ROLE_KEY never appears in a file under src/app that is not a route handler or a server action, never gets a NEXT_PUBLIC_ prefix, and never gets passed to a client component as a prop. It bypasses every policy you wrote.
  • Because sql also 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. Import sql from ./client; it is cached on globalThis so hot reload does not exhaust the connection limit.
  • prepare: false and max: 1 on 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 with bunx 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 table in the public schema ends with alter table <name> enable row level security; in the same migration, plus at least one policy. See the /add-rls-policy skill and the supabase-db-access rule.
  • 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 security from a hand-written migration in the ORM's history (drizzle-kit generate --custom, or prisma migrate dev --create-only), not from one here. db:reset replays supabase/migrations before the ORM's, so a statement here about an ORM-owned table fails on "relation does not exist". bun run verify names every table still missing RLS after a reset.
  • drop table, drop column and destructive alter ... type need 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.

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.

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.