# Next.js Supabase Cursor rules

> Next.js 16.4 and Supabase SSR, with the APIs that actually work.

Pack: Next.js + Supabase (`nextjs-supabase`)
Page: https://loadout-blush.vercel.app/packs/nextjs-supabase

## What it does

Stops the mistakes agents still make on this stack: sync params, a middleware.ts file Next.js 16 never runs, the deprecated auth-helpers package, and trusting getSession() for authorization. Rules match the Next.js 16.4 and Supabase SSR docs as of October 2026.

## Who it's for

People vibe-coding a Next.js App Router app with Supabase Auth, Postgres, and RLS.

## Versions pinned

- Next.js: 16.4.1
- React: 19.3
- @supabase/ssr: 0.12.7
- @supabase/supabase-js: current, installed alongside @supabase/ssr

Last tested: 2026-10-15

## How it was checked

doc-verified; TypeScript snippets pass tsc --strict (TypeScript 5, @types/react 19) with zod and the Supabase client stubbed

## Sources

- https://nextjs.org/blog/nextjs-security-update-october-14-2026
- https://nextjs.org/blog/next-16-4
- https://nextjs.org/docs/app/api-reference/file-conventions/proxy
- https://supabase.com/docs/guides/auth/server-side/creating-a-client
- https://www.npmjs.com/package/@supabase/ssr

## Install

### Cursor

Path: `.cursor/rules/nextjs-supabase.mdc`

1. Download the Cursor zip, or copy the .mdc file from this page.
2. Save it as .cursor/rules/nextjs-supabase.mdc.
3. Commit it. Cursor reads project rules from that folder.

### Claude Code

Path: `CLAUDE.md`

1. Download the Claude zip, or copy CLAUDE.md.
2. Put CLAUDE.md in the project root.
3. Claude Code reads it as project memory on the next session.

### AGENTS.md

Path: `AGENTS.md`

1. Download the Agents zip, or copy AGENTS.md.
2. Put AGENTS.md in the project root.
3. Agents that read AGENTS.md, including Codex and Cursor, pick it up there.

### GitHub Copilot

Path: `.github/copilot-instructions.md`

1. Download the Copilot zip, or copy the instructions file.
2. Save it as .github/copilot-instructions.md.
3. Copilot reads repository instructions from that path.

## Files

### Cursor

Install path: `.cursor/rules/nextjs-supabase.mdc`
Plain text: https://loadout-blush.vercel.app/packs/nextjs-supabase/files/cursor.mdc

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

Install path: `CLAUDE.md`
Plain text: https://loadout-blush.vercel.app/packs/nextjs-supabase/files/CLAUDE.md

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

### Agents

Install path: `AGENTS.md`
Plain text: https://loadout-blush.vercel.app/packs/nextjs-supabase/files/AGENTS.md

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

### Copilot

Install path: `.github/copilot-instructions.md`
Plain text: https://loadout-blush.vercel.app/packs/nextjs-supabase/files/copilot-instructions.md

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