dotmd

Next.js + Supabase Full-Stack

Claude Code instructions for Next.js + Supabase full-stack apps with RLS-first data access.

By dotmd TeamCC0Published Feb 19, 2026View source ↗

Install path

Use this file for each supported tool in your project.

  • Claude Code: Save as CLAUDE.md in your project at CLAUDE.md.

Configuration

CLAUDE.md

1# Next.js + Supabase Full-Stack
2
3Full-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.
4
5## Quick Reference
6
7| Task | Command |
8|---|---|
9| Dev server | `pnpm dev` |
10| Build | `pnpm build` |
11| Lint | `pnpm lint` (ESLint + Next.js rules) |
12| Type check | `pnpm typecheck` (`tsc --noEmit`) |
13| Run all tests | `pnpm test` |
14| Run single test | `pnpm test -- path/to/test.test.ts` |
15| Format | `pnpm format` (Prettier) |
16| Generate types | `pnpm supabase gen types typescript --project-id $PROJECT_ID > src/lib/database.types.ts` |
17| Supabase local start | `pnpm supabase start` |
18| Supabase local stop | `pnpm supabase stop` |
19| Supabase migration new | `pnpm supabase migration new <name>` |
20| Supabase migration up | `pnpm supabase db push` |
21| Supabase reset local DB | `pnpm supabase db reset` |
22| Supabase diff (auto-gen) | `pnpm supabase db diff -f <name>` |
23| Open Supabase Studio | `pnpm supabase start` then visit `localhost:54323` |
24
25## Project Structure
26
27```
28├── src/
29│ ├── app/
30│ │ ├── layout.tsx # Root layout — wraps children
31│ │ ├── page.tsx # Landing page
32│ │ ├── (auth)/ # Auth route group
33│ │ │ ├── login/page.tsx
34│ │ │ ├── signup/page.tsx
35│ │ │ └── callback/route.ts # OAuth + PKCE callback handler
36│ │ ├── (dashboard)/ # Protected route group
37│ │ │ ├── layout.tsx # Checks auth, redirects if unauthenticated
38│ │ │ ├── projects/
39│ │ │ │ ├── page.tsx # Server Component — fetches with Supabase
40│ │ │ │ ├── [id]/page.tsx
41│ │ │ │ └── actions.ts # Server Actions for mutations
42│ │ │ └── settings/page.tsx
43│ │ └── api/ # Route handlers (webhooks, cron, etc.)
44│ │ └── webhooks/stripe/route.ts
45│ ├── components/
46│ │ ├── ui/ # Reusable primitives (Button, Input, Card)
47│ │ └── projects/ # Feature-specific components
48│ ├── lib/
49│ │ ├── supabase/
50│ │ │ ├── client.ts # Browser client (createBrowserClient)
51│ │ │ ├── server.ts # Server client (createServerClient with cookies)
52│ │ │ ├── middleware.ts # Supabase middleware helper
53│ │ │ └── admin.ts # Service role client (admin operations)
54│ │ ├── database.types.ts # Generated — do NOT edit manually
55│ │ └── utils.ts # Shared helpers
56│ ├── hooks/ # Client-side React hooks
57│ └── middleware.ts # Next.js middleware — refreshes auth session
58├── supabase/
59│ ├── config.toml # Local Supabase config
60│ ├── migrations/ # SQL migrations (sequential, versioned)
61│ │ ├── 20240101000000_create_profiles.sql
62│ │ └── 20240102000000_create_projects.sql
63│ └── seed.sql # Seed data for local dev
64├── public/
65├── tailwind.config.ts
66├── next.config.ts
67├── tsconfig.json
68├── package.json
69└── .env.local.example
70```
71
72## Supabase Client Setup
73
74Three clients, three contexts. Never mix them.
75
76### Browser Client (Client Components)
77
78```typescript
79// src/lib/supabase/client.ts
80import { createBrowserClient } from "@supabase/ssr";
81import type { Database } from "@/lib/database.types";
82
83export function createClient() {
84 return createBrowserClient<Database>(
85 process.env.NEXT_PUBLIC_SUPABASE_URL!,
86 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
87 );
88}
89```
90
91Used in Client Components and hooks. Creates a singleton per browser tab.
92
93### Server Client (Server Components, Server Actions, Route Handlers)
94
95```typescript
96// src/lib/supabase/server.ts
97import { createServerClient } from "@supabase/ssr";
98import { cookies } from "next/headers";
99import type { Database } from "@/lib/database.types";
100
101export async function createClient() {
102 const cookieStore = await cookies();
103 return createServerClient<Database>(
104 process.env.NEXT_PUBLIC_SUPABASE_URL!,
105 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
106 {
107 cookies: {
108 getAll() { return cookieStore.getAll(); },
109 setAll(cookiesToSet) {
110 try {
111 cookiesToSet.forEach(({ name, value, options }) =>
112 cookieStore.set(name, value, options)
113 );
114 } catch {
115 // Called from Server Component — can't set cookies, but middleware handles refresh
116 }
117 },
118 },
119 }
120 );
121}
122```
123
124This is the workhorse. Always use this in Server Components, Server Actions, and Route Handlers. The `cookies()` call makes it per-request.
125
126### Admin Client (Service Role — bypasses RLS)
127
128```typescript
129// src/lib/supabase/admin.ts
130import { createClient } from "@supabase/supabase-js";
131import type { Database } from "@/lib/database.types";
132
133export const supabaseAdmin = createClient<Database>(
134 process.env.NEXT_PUBLIC_SUPABASE_URL!,
135 process.env.SUPABASE_SERVICE_ROLE_KEY!
136);
137```
138
139**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.
140
141### Middleware (Session Refresh)
142
143The 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.
144
145## Auth Patterns
146
147### Protecting Routes (Server Component)
148
149```typescript
150// src/app/(dashboard)/layout.tsx
151import { redirect } from "next/navigation";
152import { createClient } from "@/lib/supabase/server";
153
154export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
155 const supabase = await createClient();
156 const { data: { user } } = await supabase.auth.getUser();
157
158 if (!user) redirect("/login");
159
160 return <>{children}</>;
161}
162```
163
164**Always use `getUser()`**, not `getSession()`. `getUser()` validates the JWT against Supabase Auth; `getSession()` only reads the local token and can be spoofed.
165
166### Sign In / Sign Out (Server Actions)
167
168```typescript
169"use server";
170import { createClient } from "@/lib/supabase/server";
171import { redirect } from "next/navigation";
172
173export async function login(formData: FormData) {
174 const supabase = await createClient();
175 const { error } = await supabase.auth.signInWithPassword({
176 email: formData.get("email") as string,
177 password: formData.get("password") as string,
178 });
179 if (error) redirect("/login?error=Invalid+credentials");
180 redirect("/dashboard");
181}
182```
183
184For 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.
185
186## Row Level Security (RLS)
187
188RLS is non-negotiable. Every table with user data must have policies. The anon key is **public** — RLS is the only thing preventing unauthorized access.
189
190### Example: Projects Table
191
192```sql
193-- supabase/migrations/20240102000000_create_projects.sql
194create table public.projects (
195 id uuid primary key default gen_random_uuid(),
196 user_id uuid references auth.users(id) on delete cascade not null,
197 name text not null,
198 description text,
199 created_at timestamptz default now() not null,
200 updated_at timestamptz default now() not null
201);
202
203alter table public.projects enable row level security;
204
205-- Users can only see their own projects
206create policy "Users can view own projects"
207 on public.projects for select
208 using (auth.uid() = user_id);
209
210-- Users can only insert projects as themselves
211create policy "Users can create own projects"
212 on public.projects for insert
213 with check (auth.uid() = user_id);
214
215-- Users can only update their own projects
216create policy "Users can update own projects"
217 on public.projects for update
218 using (auth.uid() = user_id)
219 with check (auth.uid() = user_id);
220
221-- Users can only delete their own projects
222create policy "Users can delete own projects"
223 on public.projects for delete
224 using (auth.uid() = user_id);
225```
226
227**Rules:**
228- `using` = filter on read (SELECT) and pre-condition on UPDATE/DELETE.
229- `with check` = validation on INSERT and post-condition on UPDATE.
230- Always reference `auth.uid()` (the authenticated user's ID), not a passed parameter.
231- Test RLS policies locally: use Supabase Studio or write queries as the anon role.
232- For shared resources (team projects), join through a membership table in the policy.
233
234## Server Actions + Supabase
235
236```typescript
237// src/app/(dashboard)/projects/actions.ts
238"use server";
239
240import { revalidatePath } from "next/cache";
241import { createClient } from "@/lib/supabase/server";
242import type { Database } from "@/lib/database.types";
243
244type ProjectInsert = Database["public"]["Tables"]["projects"]["Insert"];
245
246export async function createProject(formData: FormData) {
247 const supabase = await createClient();
248 const { data: { user } } = await supabase.auth.getUser();
249 if (!user) throw new Error("Unauthorized");
250
251 const name = formData.get("name") as string;
252 if (!name || name.length > 100) throw new Error("Invalid project name");
253
254 const { error } = await supabase.from("projects").insert({
255 name,
256 user_id: user.id,
257 } satisfies ProjectInsert);
258
259 if (error) throw new Error("Failed to create project");
260 revalidatePath("/projects");
261}
262
263export async function deleteProject(projectId: string) {
264 const supabase = await createClient();
265 const { error } = await supabase.from("projects").delete().eq("id", projectId);
266
267 if (error) throw new Error("Failed to delete project");
268 revalidatePath("/projects");
269}
270```
271
272**Rules:**
273- Always validate input in Server Actions — they're public HTTP endpoints.
274- Always call `revalidatePath` or `revalidateTag` after mutations. Without it, the page shows stale data.
275- Don't pass `user_id` from the client. Get it from `auth.getUser()` server-side.
276- Use the generated `Database` types — `Tables["projects"]["Insert"]`, `Tables["projects"]["Row"]`, etc.
277
278## Data Fetching in Server Components
279
280```typescript
281// src/app/(dashboard)/projects/page.tsx
282import { createClient } from "@/lib/supabase/server";
283
284export default async function ProjectsPage() {
285 const supabase = await createClient();
286 const { data: projects, error } = await supabase
287 .from("projects")
288 .select("id, name, description, created_at")
289 .order("created_at", { ascending: false });
290
291 if (error) throw error;
292
293 return (
294 <div>
295 <h1>Projects</h1>
296 {projects.map((project) => (
297 <ProjectCard key={project.id} project={project} />
298 ))}
299 </div>
300 );
301}
302```
303
304- Fetch in Server Components. RLS handles authorization — the query automatically scopes to the authenticated user.
305- Use `.select()` with explicit columns. Don't select `*` — it defeats type narrowing and returns unnecessary data.
306- For realtime updates, use `supabase.channel()` in a Client Component with the browser client.
307
308## Type Generation
309
310Run type generation after any migration:
311
312```bash
313pnpm supabase gen types typescript --project-id $PROJECT_ID > src/lib/database.types.ts
314```
315
316For local development:
317
318```bash
319pnpm supabase gen types typescript --local > src/lib/database.types.ts
320```
321
322- **Never edit `database.types.ts` manually.** It's generated.
323- Commit the generated types. Other developers need them without running Supabase locally.
324- Helper types for convenience:
325
326```typescript
327// src/lib/database.types.ts (add at the bottom — or in a separate helpers file)
328import type { Database } from "./database.types";
329
330export type Tables<T extends keyof Database["public"]["Tables"]> =
331 Database["public"]["Tables"][T]["Row"];
332export type Enums<T extends keyof Database["public"]["Enums"]> =
333 Database["public"]["Enums"][T];
334```
335
336## Exploring the Codebase
337
338```bash
339# Find all Supabase client usages
340rg "createClient|createBrowserClient|createServerClient" src/
341
342# Find all RLS policies
343rg "create policy" supabase/migrations/
344
345# Find all Server Actions
346rg '"use server"' src/app/
347
348# Find route handlers
349find src/app -name "route.ts" -o -name "route.tsx"
350
351# Check the Supabase schema
352cat supabase/migrations/*.sql | head -200
353
354# Find all revalidation calls
355rg "revalidatePath|revalidateTag" src/
356
357# Check middleware
358cat src/middleware.ts
359
360# Find environment variables in use
361rg "process\.env\." src/lib/
362
363# Check which tables exist
364rg "create table" supabase/migrations/
365
366# Find all page components
367find src/app -name "page.tsx"
368```
369
370## When to Ask vs. Proceed
371
372**Just do it:**
373- Adding a new page that follows existing patterns
374- Creating a new Server Action for CRUD operations
375- Adding a Supabase migration for a new table with RLS policies
376- Writing or updating tests
377- Adding a new component in `src/components/`
378- Fixing lint or type errors
379- Regenerating Supabase types after a migration
380
381**Ask first:**
382- Modifying the auth flow or middleware
383- Changing the Supabase client setup (cookie handling, client creation)
384- Adding or modifying RLS policies on existing tables
385- Adding a new third-party dependency
386- Changing the database schema for an existing table (especially destructive changes)
387- Setting up realtime subscriptions or Supabase Edge Functions
388- Anything involving the service role key or admin client
389
390## Git Workflow
391
392- Branch from `main`. Name: `feat/<thing>`, `fix/<thing>`, `chore/<thing>`.
393- Commit migrations separately from application code.
394- Before pushing:
395 ```bash
396 pnpm lint
397 pnpm typecheck
398 pnpm test
399 pnpm build # Catches SSR errors that dev mode misses
400 ```
401- If types are stale after a migration, regenerate and commit them in the same PR.
402- Commit messages: imperative mood, reference ticket if one exists.
403
404## Common Pitfalls
405
406- **Using `getSession()` for auth checks.** Always use `getUser()`. `getSession()` reads the local JWT without server validation — it can be spoofed by modifying cookies.
407- **Missing middleware.** Without the middleware that calls `getUser()`, auth tokens silently expire and all Supabase queries return empty results.
408- **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.
409- **Stale types.** After changing a migration, regenerate `database.types.ts`. Stale types cause runtime errors that TypeScript can't catch.
410- **Client-side mutations without revalidation.** After a Supabase mutation via Server Action, call `revalidatePath`. Otherwise the cached Server Component shows stale data.
411- **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.
412- **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()`.
413- **Forgetting `await` on `cookies()`.** In Next.js 15+, `cookies()` is async. Missing `await` causes the Supabase client to fail silently.
414

Community feedback

0 found this helpful

Works with: