---
description: Next.js 16.4 App Router with @supabase/ssr 0.12.7. Async params, proxy.ts, getClaims, RLS.
globs: "{app,src,lib,components}/**/*.{ts,tsx},proxy.ts,middleware.ts,**/*.sql"
alwaysApply: false
---
# Next.js 16.4 + Supabase SSR
Pinned on 2026-10-15: Next.js 16.4.1 (App Router), React 19.3, `@supabase/ssr` 0.12.7, `@supabase/supabase-js`. Do not generate Pages Router, `middleware.ts`, or `@supabase/auth-helpers-nextjs`.
## Routing and components
- Server Components are the default. Add `"use client"` only when the file uses state, effects, browser APIs, or event handlers. Push that file to a leaf component.
- `params` and `searchParams` are Promises. Always `await` them. Synchronous access typechecks in places and crashes at runtime.
- `cookies()`, `headers()`, and `draftMode()` from `next/headers` are async. `await` them.
- On Next.js 16 the request interceptor is `proxy.ts` and the export is `proxy`. A file named `middleware.ts` is not called, so sessions never refresh.
- New `create-next-app` projects enable Cache Components. Do not cache a response that depends on a cookie or a user. Authenticated reads stay dynamic.
```tsx
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return
{id}
}
```
## Supabase clients
- Install `@supabase/supabase-js` and `@supabase/ssr`. Never import `@supabase/auth-helpers-nextjs`.
- Env names from the current docs: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`. The secret key and the service-role key stay server-only, with no `NEXT_PUBLIC_` prefix.
- Browser code uses `createBrowserClient`. Server Components, Server Actions, and Route Handlers use `createServerClient` with `cookies.getAll` / `cookies.setAll`.
- `setAll` receives a second argument, the cache headers. Apply `Cache-Control`, `Expires`, and `Pragma` on the response. Skipping them lets a CDN store one user's session cookie and serve it to someone else.
- In a Server Component, `setAll` cannot write cookies. Catch and ignore that error. Refreshing happens in `proxy.ts`.
## Auth
- To decide whether a request is allowed, call `supabase.auth.getClaims()`. It verifies the JWT (locally against JWKS on asymmetric keys, which new projects use).
- Use `getUser()` only when you need the current user row from the Auth server. Use `getSession()` only to read the raw token, and never trust `session.user` for authorization.
- `proxy.ts` refreshes the session by calling `getClaims()`, copies cookies onto both the request and the response, and returns the response that `setAll` last built. Pages and actions still call `getClaims()` themselves. Proxy is not the only lock.
- Sign-in and sign-out go through Server Actions, not a client `fetch` to your own route.
## Database
- Every table in `public` has RLS enabled. Add policies only for the operations clients should be allowed to do, because any operation without a policy is denied. A table without RLS is readable and writable through the Data API under the default grants.
- Select named columns. Do not `.select("*")` and do not return the whole row to the client.
- Validate mutation input with Zod, then check `getClaims()`, then write. Return a typed `{ ok, error }` object, not a thrown string.
```ts
const { data, error } = await supabase
.from("notes")
.select("id, title, created_at")
.eq("user_id", claims.sub)
```
## Server Actions and forms
- Put `"use server"` at the top of an actions file, or inside the function body. Do not export non-async values from a `"use server"` file.
- Every action re-checks auth with `getClaims()`. An action is a public HTTP endpoint even if no button calls it.
- After a write, call `revalidatePath` or `revalidateTag` for the page that shows the data. Do not force a full `router.refresh()` from the client to fix stale data.
```ts
"use server"
import { z } from "zod"
import { createClient } from "@/lib/supabase/server"
const NoteInput = z.object({ title: z.string().min(1).max(200) })
export async function createNote(formData: FormData) {
const parsed = NoteInput.safeParse({ title: formData.get("title") })
if (!parsed.success) return { ok: false, error: "Invalid title" }
const supabase = await createClient()
const { data, error } = await supabase.auth.getClaims()
if (error || !data) return { ok: false, error: "Not signed in" }
const { error: insertError } = await supabase
.from("notes")
.insert({ title: parsed.data.title, user_id: data.claims.sub })
if (insertError) return { ok: false, error: insertError.message }
return { ok: true }
}
```
## Deploying on Vercel
- Set `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` in the Vercel project for Preview and Production.
- Keep the Supabase secret key out of the client bundle. Search the build for `NEXT_PUBLIC_` names before shipping.
- Add every deployed URL (production and preview pattern) to Supabase Auth's redirect allow list, or OAuth and magic links fail.
## Done checklist
1. `npx tsc --noEmit` and `next build` pass, or you said why they could not run.
2. New tables have RLS enabled plus policies, in a migration file.
3. No `getSession()` is used for authorization, and no `select("*")` was added.