# 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 <h1>{id}</h1>
}
```

## 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.

## Claude Code
- When you add a Supabase client, put the helpers in `lib/supabase/client.ts` (browser) and `lib/supabase/server.ts` (server). Do not create a third copy inside a component.
- After editing auth or data code, run `npx tsc --noEmit` if the repo has a `tsconfig.json`, and say whether it passed.
