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