// Config Record
>Next.js + Supabase Full-Stack
Claude Code instructions for Next.js + Supabase full-stack apps with RLS-first data access.
author:
dotmd Team
license:CC0
published:Feb 19, 2026
// Installation
>Add this file to your project repository:
- Claude Code--path=
CLAUDE.md
// File Content
CLAUDE.md
1# CLAUDE.md — Next.js + Supabase Full-Stack23This file contains your project instructions for Claude Code. Apply these4coding conventions, architecture decisions, and workflow preferences to every5change you make in this repository.67---89Full-stack app with Next.js (App Router) and Supabase. Prioritize server-first rendering, type-safe Supabase queries, RLS for all data access, and auth via `@supabase/ssr`. Every change must pass `pnpm lint && pnpm typecheck && pnpm test` before committing.1011## Quick Reference1213| Task | Command |14|---|---|15| Dev server | `pnpm dev` |16| Build | `pnpm build` |17| Lint | `pnpm lint` (ESLint + Next.js rules) |18| Type check | `pnpm typecheck` (`tsc --noEmit`) |19| Run all tests | `pnpm test` |20| Run single test | `pnpm test -- path/to/test.test.ts` |21| Format | `pnpm format` (Prettier) |22| Generate types | `pnpm supabase gen types typescript --project-id $PROJECT_ID > src/lib/database.types.ts` |23| Supabase local start | `pnpm supabase start` |24| Supabase local stop | `pnpm supabase stop` |25| Supabase migration new | `pnpm supabase migration new <name>` |26| Supabase migration up | `pnpm supabase db push` |27| Supabase reset local DB | `pnpm supabase db reset` |28| Supabase diff (auto-gen) | `pnpm supabase db diff -f <name>` |29| Open Supabase Studio | `pnpm supabase start` then visit `localhost:54323` |3031## Project Structure3233```34├── src/35│ ├── app/36│ │ ├── layout.tsx # Root layout — wraps children37│ │ ├── page.tsx # Landing page38│ │ ├── (auth)/ # Auth route group39│ │ │ ├── login/page.tsx40│ │ │ ├── signup/page.tsx41│ │ │ └── callback/route.ts # OAuth + PKCE callback handler42│ │ ├── (dashboard)/ # Protected route group43│ │ │ ├── layout.tsx # Checks auth, redirects if unauthenticated44│ │ │ ├── projects/45│ │ │ │ ├── page.tsx # Server Component — fetches with Supabase46│ │ │ │ ├── [id]/page.tsx47│ │ │ │ └── actions.ts # Server Actions for mutations48│ │ │ └── settings/page.tsx49│ │ └── api/ # Route handlers (webhooks, cron, etc.)50│ │ └── webhooks/stripe/route.ts51│ ├── components/52│ │ ├── ui/ # Reusable primitives (Button, Input, Card)53│ │ └── projects/ # Feature-specific components54│ ├── lib/55│ │ ├── supabase/56│ │ │ ├── client.ts # Browser client (createBrowserClient)57│ │ │ ├── server.ts # Server client (createServerClient with cookies)58│ │ │ ├── middleware.ts # Supabase middleware helper59│ │ │ └── admin.ts # Service role client (admin operations)60│ │ ├── database.types.ts # Generated — do NOT edit manually61│ │ └── utils.ts # Shared helpers62│ ├── hooks/ # Client-side React hooks63│ └── middleware.ts # Next.js middleware — refreshes auth session64├── supabase/65│ ├── config.toml # Local Supabase config66│ ├── migrations/ # SQL migrations (sequential, versioned)67│ │ ├── 20240101000000_create_profiles.sql68│ │ └── 20240102000000_create_projects.sql69│ └── seed.sql # Seed data for local dev70├── public/71├── tailwind.config.ts72├── next.config.ts73├── tsconfig.json74├── package.json75└── .env.local.example76```7778## Supabase Client Setup7980Three clients, three contexts. Never mix them.8182### Browser Client (Client Components)8384```typescript85// src/lib/supabase/client.ts86import { createBrowserClient } from "@supabase/ssr";87import type { Database } from "@/lib/database.types";8889export function createClient() {90 return createBrowserClient<Database>(91 process.env.NEXT_PUBLIC_SUPABASE_URL!,92 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!93 );94}95```9697Used in Client Components and hooks. Creates a singleton per browser tab.9899### Server Client (Server Components, Server Actions, Route Handlers)100101```typescript102// src/lib/supabase/server.ts103import { createServerClient } from "@supabase/ssr";104import { cookies } from "next/headers";105import type { Database } from "@/lib/database.types";106107export async function createClient() {108 const cookieStore = await cookies();109 return createServerClient<Database>(110 process.env.NEXT_PUBLIC_SUPABASE_URL!,111 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,112 {113 cookies: {114 getAll() { return cookieStore.getAll(); },115 setAll(cookiesToSet) {116 try {117 cookiesToSet.forEach(({ name, value, options }) =>118 cookieStore.set(name, value, options)119 );120 } catch {121 // Called from Server Component — can't set cookies, but middleware handles refresh122 }123 },124 },125 }126 );127}128```129130This is the workhorse. Always use this in Server Components, Server Actions, and Route Handlers. The `cookies()` call makes it per-request.131132### Admin Client (Service Role — bypasses RLS)133134```typescript135// src/lib/supabase/admin.ts136import { createClient } from "@supabase/supabase-js";137import type { Database } from "@/lib/database.types";138139export const supabaseAdmin = createClient<Database>(140 process.env.NEXT_PUBLIC_SUPABASE_URL!,141 process.env.SUPABASE_SERVICE_ROLE_KEY!142);143```144145**Only** for server-side admin operations: webhooks, cron jobs, data migrations. Never expose the service role key to the browser. Never use this for user-facing queries.146147### Middleware (Session Refresh)148149The middleware in `src/middleware.ts` creates a Supabase server client using request/response cookies, calls `supabase.auth.getUser()` to refresh the token, and passes updated cookies through. This is **required** — without it, sessions silently expire. See the [Supabase SSR docs](https://supabase.com/docs/guides/auth/server-side/nextjs) for the full cookie-handling boilerplate. Match against all routes except static assets.150151## Auth Patterns152153### Protecting Routes (Server Component)154155```typescript156// src/app/(dashboard)/layout.tsx157import { redirect } from "next/navigation";158import { createClient } from "@/lib/supabase/server";159160export default async function DashboardLayout({ children }: { children: React.ReactNode }) {161 const supabase = await createClient();162 const { data: { user } } = await supabase.auth.getUser();163164 if (!user) redirect("/login");165166 return <>{children}</>;167}168```169170**Always use `getUser()`**, not `getSession()`. `getUser()` validates the JWT against Supabase Auth; `getSession()` only reads the local token and can be spoofed.171172### Sign In / Sign Out (Server Actions)173174```typescript175"use server";176import { createClient } from "@/lib/supabase/server";177import { redirect } from "next/navigation";178179export async function login(formData: FormData) {180 const supabase = await createClient();181 const { error } = await supabase.auth.signInWithPassword({182 email: formData.get("email") as string,183 password: formData.get("password") as string,184 });185 if (error) redirect("/login?error=Invalid+credentials");186 redirect("/dashboard");187}188```189190For OAuth, the callback route handler (`src/app/(auth)/callback/route.ts`) extracts the `code` param, calls `supabase.auth.exchangeCodeForSession(code)`, and redirects to the dashboard on success.191192## Row Level Security (RLS)193194RLS is non-negotiable. Every table with user data must have policies. The anon key is **public** — RLS is the only thing preventing unauthorized access.195196### Example: Projects Table197198```sql199-- supabase/migrations/20240102000000_create_projects.sql200create table public.projects (201 id uuid primary key default gen_random_uuid(),202 user_id uuid references auth.users(id) on delete cascade not null,203 name text not null,204 description text,205 created_at timestamptz default now() not null,206 updated_at timestamptz default now() not null207);208209alter table public.projects enable row level security;210211-- Users can only see their own projects212create policy "Users can view own projects"213 on public.projects for select214 using (auth.uid() = user_id);215216-- Users can only insert projects as themselves217create policy "Users can create own projects"218 on public.projects for insert219 with check (auth.uid() = user_id);220221-- Users can only update their own projects222create policy "Users can update own projects"223 on public.projects for update224 using (auth.uid() = user_id)225 with check (auth.uid() = user_id);226227-- Users can only delete their own projects228create policy "Users can delete own projects"229 on public.projects for delete230 using (auth.uid() = user_id);231```232233**Rules:**234- `using` = filter on read (SELECT) and pre-condition on UPDATE/DELETE.235- `with check` = validation on INSERT and post-condition on UPDATE.236- Always reference `auth.uid()` (the authenticated user's ID), not a passed parameter.237- Test RLS policies locally: use Supabase Studio or write queries as the anon role.238- For shared resources (team projects), join through a membership table in the policy.239240## Server Actions + Supabase241242```typescript243// src/app/(dashboard)/projects/actions.ts244"use server";245246import { revalidatePath } from "next/cache";247import { createClient } from "@/lib/supabase/server";248import type { Database } from "@/lib/database.types";249250type ProjectInsert = Database["public"]["Tables"]["projects"]["Insert"];251252export async function createProject(formData: FormData) {253 const supabase = await createClient();254 const { data: { user } } = await supabase.auth.getUser();255 if (!user) throw new Error("Unauthorized");256257 const name = formData.get("name") as string;258 if (!name || name.length > 100) throw new Error("Invalid project name");259260 const { error } = await supabase.from("projects").insert({261 name,262 user_id: user.id,263 } satisfies ProjectInsert);264265 if (error) throw new Error("Failed to create project");266 revalidatePath("/projects");267}268269export async function deleteProject(projectId: string) {270 const supabase = await createClient();271 const { error } = await supabase.from("projects").delete().eq("id", projectId);272273 if (error) throw new Error("Failed to delete project");274 revalidatePath("/projects");275}276```277278**Rules:**279- Always validate input in Server Actions — they're public HTTP endpoints.280- Always call `revalidatePath` or `revalidateTag` after mutations. Without it, the page shows stale data.281- Don't pass `user_id` from the client. Get it from `auth.getUser()` server-side.282- Use the generated `Database` types — `Tables["projects"]["Insert"]`, `Tables["projects"]["Row"]`, etc.283284## Data Fetching in Server Components285286```typescript287// src/app/(dashboard)/projects/page.tsx288import { createClient } from "@/lib/supabase/server";289290export default async function ProjectsPage() {291 const supabase = await createClient();292 const { data: projects, error } = await supabase293 .from("projects")294 .select("id, name, description, created_at")295 .order("created_at", { ascending: false });296297 if (error) throw error;298299 return (300 <div>301 <h1>Projects</h1>302 {projects.map((project) => (303 <ProjectCard key={project.id} project={project} />304 ))}305 </div>306 );307}308```309310- Fetch in Server Components. RLS handles authorization — the query automatically scopes to the authenticated user.311- Use `.select()` with explicit columns. Don't select `*` — it defeats type narrowing and returns unnecessary data.312- For realtime updates, use `supabase.channel()` in a Client Component with the browser client.313314## Type Generation315316Run type generation after any migration:317318```bash319pnpm supabase gen types typescript --project-id $PROJECT_ID > src/lib/database.types.ts320```321322For local development:323324```bash325pnpm supabase gen types typescript --local > src/lib/database.types.ts326```327328- **Never edit `database.types.ts` manually.** It's generated.329- Commit the generated types. Other developers need them without running Supabase locally.330- Helper types for convenience:331332```typescript333// src/lib/database.types.ts (add at the bottom — or in a separate helpers file)334import type { Database } from "./database.types";335336export type Tables<T extends keyof Database["public"]["Tables"]> =337 Database["public"]["Tables"][T]["Row"];338export type Enums<T extends keyof Database["public"]["Enums"]> =339 Database["public"]["Enums"][T];340```341342## Exploring the Codebase343344```bash345# Find all Supabase client usages346rg "createClient|createBrowserClient|createServerClient" src/347348# Find all RLS policies349rg "create policy" supabase/migrations/350351# Find all Server Actions352rg '"use server"' src/app/353354# Find route handlers355find src/app -name "route.ts" -o -name "route.tsx"356357# Check the Supabase schema358cat supabase/migrations/*.sql | head -200359360# Find all revalidation calls361rg "revalidatePath|revalidateTag" src/362363# Check middleware364cat src/middleware.ts365366# Find environment variables in use367rg "process\.env\." src/lib/368369# Check which tables exist370rg "create table" supabase/migrations/371372# Find all page components373find src/app -name "page.tsx"374```375376## When to Ask vs. Proceed377378**Just do it:**379- Adding a new page that follows existing patterns380- Creating a new Server Action for CRUD operations381- Adding a Supabase migration for a new table with RLS policies382- Writing or updating tests383- Adding a new component in `src/components/`384- Fixing lint or type errors385- Regenerating Supabase types after a migration386387**Ask first:**388- Modifying the auth flow or middleware389- Changing the Supabase client setup (cookie handling, client creation)390- Adding or modifying RLS policies on existing tables391- Adding a new third-party dependency392- Changing the database schema for an existing table (especially destructive changes)393- Setting up realtime subscriptions or Supabase Edge Functions394- Anything involving the service role key or admin client395396## Git Workflow397398- Branch from `main`. Name: `feat/<thing>`, `fix/<thing>`, `chore/<thing>`.399- Commit migrations separately from application code.400- Before pushing:401 ```bash402 pnpm lint403 pnpm typecheck404 pnpm test405 pnpm build # Catches SSR errors that dev mode misses406 ```407- If types are stale after a migration, regenerate and commit them in the same PR.408- Commit messages: imperative mood, reference ticket if one exists.409410## Common Pitfalls411412- **Using `getSession()` for auth checks.** Always use `getUser()`. `getSession()` reads the local JWT without server validation — it can be spoofed by modifying cookies.413- **Missing middleware.** Without the middleware that calls `getUser()`, auth tokens silently expire and all Supabase queries return empty results.414- **Forgetting RLS on new tables.** If you create a table without `enable row level security`, the anon key grants full public access. Always add policies.415- **Stale types.** After changing a migration, regenerate `database.types.ts`. Stale types cause runtime errors that TypeScript can't catch.416- **Client-side mutations without revalidation.** After a Supabase mutation via Server Action, call `revalidatePath`. Otherwise the cached Server Component shows stale data.417- **Using the admin client for user queries.** The admin client bypasses RLS. Use it only for webhooks, cron, and admin operations — never for user-facing queries.418- **Mixing client types.** Browser client in a Server Component or server client in a Client Component both break silently. Browser client uses `createBrowserClient`, server uses `createServerClient` with `cookies()`.419- **Forgetting `await` on `cookies()`.** In Next.js 15+, `cookies()` is async. Missing `await` causes the Supabase client to fail silently.420