dotmd
// 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
    CLAUDE.md
// File Content
CLAUDE.md
1# CLAUDE.md — Next.js + Supabase Full-Stack
2
3This file contains your project instructions for Claude Code. Apply these
4coding conventions, architecture decisions, and workflow preferences to every
5change you make in this repository.
6
7---
8
9Full-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.
10
11## Quick Reference
12
13| 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` |
30
31## Project Structure
32
33```
34├── src/
35│ ├── app/
36│ │ ├── layout.tsx # Root layout — wraps children
37│ │ ├── page.tsx # Landing page
38│ │ ├── (auth)/ # Auth route group
39│ │ │ ├── login/page.tsx
40│ │ │ ├── signup/page.tsx
41│ │ │ └── callback/route.ts # OAuth + PKCE callback handler
42│ │ ├── (dashboard)/ # Protected route group
43│ │ │ ├── layout.tsx # Checks auth, redirects if unauthenticated
44│ │ │ ├── projects/
45│ │ │ │ ├── page.tsx # Server Component — fetches with Supabase
46│ │ │ │ ├── [id]/page.tsx
47│ │ │ │ └── actions.ts # Server Actions for mutations
48│ │ │ └── settings/page.tsx
49│ │ └── api/ # Route handlers (webhooks, cron, etc.)
50│ │ └── webhooks/stripe/route.ts
51│ ├── components/
52│ │ ├── ui/ # Reusable primitives (Button, Input, Card)
53│ │ └── projects/ # Feature-specific components
54│ ├── lib/
55│ │ ├── supabase/
56│ │ │ ├── client.ts # Browser client (createBrowserClient)
57│ │ │ ├── server.ts # Server client (createServerClient with cookies)
58│ │ │ ├── middleware.ts # Supabase middleware helper
59│ │ │ └── admin.ts # Service role client (admin operations)
60│ │ ├── database.types.ts # Generated — do NOT edit manually
61│ │ └── utils.ts # Shared helpers
62│ ├── hooks/ # Client-side React hooks
63│ └── middleware.ts # Next.js middleware — refreshes auth session
64├── supabase/
65│ ├── config.toml # Local Supabase config
66│ ├── migrations/ # SQL migrations (sequential, versioned)
67│ │ ├── 20240101000000_create_profiles.sql
68│ │ └── 20240102000000_create_projects.sql
69│ └── seed.sql # Seed data for local dev
70├── public/
71├── tailwind.config.ts
72├── next.config.ts
73├── tsconfig.json
74├── package.json
75└── .env.local.example
76```
77
78## Supabase Client Setup
79
80Three clients, three contexts. Never mix them.
81
82### Browser Client (Client Components)
83
84```typescript
85// src/lib/supabase/client.ts
86import { createBrowserClient } from "@supabase/ssr";
87import type { Database } from "@/lib/database.types";
88
89export function createClient() {
90 return createBrowserClient<Database>(
91 process.env.NEXT_PUBLIC_SUPABASE_URL!,
92 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
93 );
94}
95```
96
97Used in Client Components and hooks. Creates a singleton per browser tab.
98
99### Server Client (Server Components, Server Actions, Route Handlers)
100
101```typescript
102// src/lib/supabase/server.ts
103import { createServerClient } from "@supabase/ssr";
104import { cookies } from "next/headers";
105import type { Database } from "@/lib/database.types";
106
107export 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 refresh
122 }
123 },
124 },
125 }
126 );
127}
128```
129
130This is the workhorse. Always use this in Server Components, Server Actions, and Route Handlers. The `cookies()` call makes it per-request.
131
132### Admin Client (Service Role — bypasses RLS)
133
134```typescript
135// src/lib/supabase/admin.ts
136import { createClient } from "@supabase/supabase-js";
137import type { Database } from "@/lib/database.types";
138
139export const supabaseAdmin = createClient<Database>(
140 process.env.NEXT_PUBLIC_SUPABASE_URL!,
141 process.env.SUPABASE_SERVICE_ROLE_KEY!
142);
143```
144
145**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.
146
147### Middleware (Session Refresh)
148
149The 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.
150
151## Auth Patterns
152
153### Protecting Routes (Server Component)
154
155```typescript
156// src/app/(dashboard)/layout.tsx
157import { redirect } from "next/navigation";
158import { createClient } from "@/lib/supabase/server";
159
160export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
161 const supabase = await createClient();
162 const { data: { user } } = await supabase.auth.getUser();
163
164 if (!user) redirect("/login");
165
166 return <>{children}</>;
167}
168```
169
170**Always use `getUser()`**, not `getSession()`. `getUser()` validates the JWT against Supabase Auth; `getSession()` only reads the local token and can be spoofed.
171
172### Sign In / Sign Out (Server Actions)
173
174```typescript
175"use server";
176import { createClient } from "@/lib/supabase/server";
177import { redirect } from "next/navigation";
178
179export 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```
189
190For 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.
191
192## Row Level Security (RLS)
193
194RLS is non-negotiable. Every table with user data must have policies. The anon key is **public** — RLS is the only thing preventing unauthorized access.
195
196### Example: Projects Table
197
198```sql
199-- supabase/migrations/20240102000000_create_projects.sql
200create 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 null
207);
208
209alter table public.projects enable row level security;
210
211-- Users can only see their own projects
212create policy "Users can view own projects"
213 on public.projects for select
214 using (auth.uid() = user_id);
215
216-- Users can only insert projects as themselves
217create policy "Users can create own projects"
218 on public.projects for insert
219 with check (auth.uid() = user_id);
220
221-- Users can only update their own projects
222create policy "Users can update own projects"
223 on public.projects for update
224 using (auth.uid() = user_id)
225 with check (auth.uid() = user_id);
226
227-- Users can only delete their own projects
228create policy "Users can delete own projects"
229 on public.projects for delete
230 using (auth.uid() = user_id);
231```
232
233**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.
239
240## Server Actions + Supabase
241
242```typescript
243// src/app/(dashboard)/projects/actions.ts
244"use server";
245
246import { revalidatePath } from "next/cache";
247import { createClient } from "@/lib/supabase/server";
248import type { Database } from "@/lib/database.types";
249
250type ProjectInsert = Database["public"]["Tables"]["projects"]["Insert"];
251
252export 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");
256
257 const name = formData.get("name") as string;
258 if (!name || name.length > 100) throw new Error("Invalid project name");
259
260 const { error } = await supabase.from("projects").insert({
261 name,
262 user_id: user.id,
263 } satisfies ProjectInsert);
264
265 if (error) throw new Error("Failed to create project");
266 revalidatePath("/projects");
267}
268
269export async function deleteProject(projectId: string) {
270 const supabase = await createClient();
271 const { error } = await supabase.from("projects").delete().eq("id", projectId);
272
273 if (error) throw new Error("Failed to delete project");
274 revalidatePath("/projects");
275}
276```
277
278**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.
283
284## Data Fetching in Server Components
285
286```typescript
287// src/app/(dashboard)/projects/page.tsx
288import { createClient } from "@/lib/supabase/server";
289
290export default async function ProjectsPage() {
291 const supabase = await createClient();
292 const { data: projects, error } = await supabase
293 .from("projects")
294 .select("id, name, description, created_at")
295 .order("created_at", { ascending: false });
296
297 if (error) throw error;
298
299 return (
300 <div>
301 <h1>Projects</h1>
302 {projects.map((project) => (
303 <ProjectCard key={project.id} project={project} />
304 ))}
305 </div>
306 );
307}
308```
309
310- 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.
313
314## Type Generation
315
316Run type generation after any migration:
317
318```bash
319pnpm supabase gen types typescript --project-id $PROJECT_ID > src/lib/database.types.ts
320```
321
322For local development:
323
324```bash
325pnpm supabase gen types typescript --local > src/lib/database.types.ts
326```
327
328- **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:
331
332```typescript
333// src/lib/database.types.ts (add at the bottom — or in a separate helpers file)
334import type { Database } from "./database.types";
335
336export 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```
341
342## Exploring the Codebase
343
344```bash
345# Find all Supabase client usages
346rg "createClient|createBrowserClient|createServerClient" src/
347
348# Find all RLS policies
349rg "create policy" supabase/migrations/
350
351# Find all Server Actions
352rg '"use server"' src/app/
353
354# Find route handlers
355find src/app -name "route.ts" -o -name "route.tsx"
356
357# Check the Supabase schema
358cat supabase/migrations/*.sql | head -200
359
360# Find all revalidation calls
361rg "revalidatePath|revalidateTag" src/
362
363# Check middleware
364cat src/middleware.ts
365
366# Find environment variables in use
367rg "process\.env\." src/lib/
368
369# Check which tables exist
370rg "create table" supabase/migrations/
371
372# Find all page components
373find src/app -name "page.tsx"
374```
375
376## When to Ask vs. Proceed
377
378**Just do it:**
379- Adding a new page that follows existing patterns
380- Creating a new Server Action for CRUD operations
381- Adding a Supabase migration for a new table with RLS policies
382- Writing or updating tests
383- Adding a new component in `src/components/`
384- Fixing lint or type errors
385- Regenerating Supabase types after a migration
386
387**Ask first:**
388- Modifying the auth flow or middleware
389- Changing the Supabase client setup (cookie handling, client creation)
390- Adding or modifying RLS policies on existing tables
391- Adding a new third-party dependency
392- Changing the database schema for an existing table (especially destructive changes)
393- Setting up realtime subscriptions or Supabase Edge Functions
394- Anything involving the service role key or admin client
395
396## Git Workflow
397
398- Branch from `main`. Name: `feat/<thing>`, `fix/<thing>`, `chore/<thing>`.
399- Commit migrations separately from application code.
400- Before pushing:
401 ```bash
402 pnpm lint
403 pnpm typecheck
404 pnpm test
405 pnpm build # Catches SSR errors that dev mode misses
406 ```
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.
409
410## Common Pitfalls
411
412- **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