Next.js + Supabase
Project context
This is a Next.js 15 App Router project that uses Supabase for authentication, Postgres, and (optionally) Storage. Auth and data access run server-side; row-level security (RLS) is the source of truth for authorization. Never leak the service-role key into client code.
Stack
- Next.js 15 App Router, React 19, TypeScript strict
- Supabase (Postgres + Auth + Storage)
@supabase/supabase-jsand@supabase/ssrfor SSR-aware clients- Tailwind 4 for styling
- pnpm as the package manager
Environment
NEXT_PUBLIC_SUPABASE_URL= — public, embedded in client bundle
NEXT_PUBLIC_SUPABASE_ANON_KEY= — public, embedded in client bundle
SUPABASE_SERVICE_ROLE_KEY= — SERVER ONLY, never in client code
DATABASE_URL= — only if using direct Postgres connection
The service-role key bypasses RLS. It must only ever be used inside 'use server' files or import 'server-only' modules.
Three Supabase clients
Use the right client for the context. Never mix them.
createBrowserClient — used in client components for auth state and realtime subscriptions. Reads the anon key from NEXT_PUBLIC_* env vars.
createServerClient — used in server components, server actions, and route handlers. Reads cookies for auth state.
createAdminClient — service-role client for admin operations (sending emails, modifying users, bypassing RLS). Server-only. Imported only from files marked 'use server' or import 'server-only'.
// lib/supabase/server.ts
import 'server-only'
import { cookies } from 'next/headers'
import { createServerClient as create } from '@supabase/ssr'
export async function createServerClient() {
const store = await cookies()
return create(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => store.getAll(),
setAll: (list) => list.forEach(({ name, value, options }) =>
store.set(name, value, options)
),
},
}
)
}
Auth pattern
- Sign-in / sign-up flows happen via Server Actions or route handlers, never directly from the browser
- After sign-in, redirect via
redirect()fromnext/navigation - Use middleware to refresh the session on every request
- Get the current user with
await supabase.auth.getUser()— this validates with the auth server, unlikegetSession()which only reads the cookie
// In a server component:
const supabase = await createServerClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
Database access — RLS first
- Define RLS policies on every table —
enable row level securityis the default - Write the policies in SQL migrations, not the Supabase dashboard (so they're versioned)
- Test policies with both authenticated and anonymous clients
- Use the regular client (with RLS) for user-facing queries
- Use the admin client only for operations the user shouldn't be allowed to do (sending invitation emails, deleting other users' data, etc.)
Type generation
- Run
supabase gen types typescript --linked > types/supabase.tsafter every migration - Import
Databasefrom there and pass tocreateClient<Database>for type-safe queries - Never hand-write Supabase types — always regenerate
import type { Database } from '@/types/supabase'
const supabase = createServerClient<Database>(...)
Storage
- Use signed URLs for private buckets — never expose direct paths
- Generate signed URLs server-side; pass them to clients with a short TTL
- Set bucket policies the same way as RLS — versioned in migrations
Migrations
- Use
supabase migration new <name>to create migrations - Never modify a migration after it's been committed — create a new one
- Run
supabase db resetlocally before pushing to verify migrations apply cleanly from scratch - Keep RLS policies in the same migration as the table they protect
Testing
- Use a local Supabase instance for tests (
supabase start) - Reset the DB between tests via
supabase db resetor transaction rollback - Test RLS by signing in as different users and verifying the right rows are visible/hidden
Patterns to avoid
- Never embed the service-role key in client code. Search-and-grep for it before every commit.
- Never call
getSession()for authorization. It only reads the cookie. UsegetUser()which validates with the server. - Never disable RLS. If a query needs to bypass it, use the admin client; don't disable the policy.
- Don't mix anon and service-role in one file. Pick one per module.
- Don't query Supabase from
useEffect. Fetch in a server component, pass via props.
Tooling
pnpm dev— Next.js devsupabase start— local Supabase stack (Postgres, Auth, Storage, Studio)supabase migration new <name>— create a migrationsupabase db reset— wipe and re-apply migrations locallysupabase gen types typescript --linked— regenerate types
AI behavioral rules
- Always grep for
SUPABASE_SERVICE_ROLE_KEYin client-bundled files before committing - When fetching user data, prefer RLS-enforced queries from the regular client
- When asked to add a new table, generate the migration with RLS policies in the same file
- After creating a migration, regenerate types — never hand-edit
types/supabase.ts - When asked to add an admin operation, mark the file with
'use server'orimport 'server-only'and explain why - Never call
getSession()for auth gating — always usegetUser() - Run lint, typecheck, and tests before declaring a task done