Next.js + Supabase Full-Stack
Claude Code instructions for Next.js + Supabase full-stack apps with RLS-first data access.
Install path
Use this file for each supported tool in your project.
- Claude Code: Save as
CLAUDE.mdin your project atCLAUDE.md.
Configuration
CLAUDE.md
1# Next.js + Supabase Full-Stack23Full-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.45## Quick Reference67| 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` |2425## Project Structure2627```28├── src/29│ ├── app/30│ │ ├── layout.tsx # Root layout — wraps children31│ │ ├── page.tsx # Landing page32│ │ ├── (auth)/ # Auth route group33│ │ │ ├── login/page.tsx34│ │ │ ├── signup/page.tsx35│ │ │ └── callback/route.ts # OAuth + PKCE callback handler36│ │ ├── (dashboard)/ # Protected route group37│ │ │ ├── layout.tsx # Checks auth, redirects if unauthenticated38│ │ │ ├── projects/39│ │ │ │ ├── page.tsx # Server Component — fetches with Supabase40│ │ │ │ ├── [id]/page.tsx41│ │ │ │ └── actions.ts # Server Actions for mutations42│ │ │ └── settings/page.tsx43│ │ └── api/ # Route handlers (webhooks, cron, etc.)44│ │ └── webhooks/stripe/route.ts45│ ├── components/46│ │ ├── ui/ # Reusable primitives (Button, Input, Card)47│ │ └── projects/ # Feature-specific components48│ ├── lib/49│ │ ├── supabase/50│ │ │ ├── client.ts # Browser client (createBrowserClient)51│ │ │ ├── server.ts # Server client (createServerClient with cookies)52│ │ │ ├── middleware.ts # Supabase middleware helper53│ │ │ └── admin.ts # Service role client (admin operations)54│ │ ├── database.types.ts # Generated — do NOT edit manually55│ │ └── utils.ts # Shared helpers56│ ├── hooks/ # Client-side React hooks57│ └── middleware.ts # Next.js middleware — refreshes auth session58├── supabase/59│ ├── config.toml # Local Supabase config60│ ├── migrations/ # SQL migrations (sequential, versioned)61│ │ ├── 20240101000000_create_profiles.sql62│ │ └── 20240102000000_create_projects.sql63│ └── seed.sql # Seed data for local dev64├── public/65├── tailwind.config.ts66├── next.config.ts67├── tsconfig.json68├── package.json69└── .env.local.example70```7172## Supabase Client Setup7374Three clients, three contexts. Never mix them.7576### Browser Client (Client Components)7778```typescript79// src/lib/supabase/client.ts80import { createBrowserClient } from "@supabase/ssr";81import type { Database } from "@/lib/database.types";8283export function createClient() {84 return createBrowserClient<Database>(85 process.env.NEXT_PUBLIC_SUPABASE_URL!,86 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!87 );88}89```9091Used in Client Components and hooks. Creates a singleton per browser tab.9293### Server Client (Server Components, Server Actions, Route Handlers)9495```typescript96// src/lib/supabase/server.ts97import { createServerClient } from "@supabase/ssr";98import { cookies } from "next/headers";99import type { Database } from "@/lib/database.types";100101export 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 refresh116 }117 },118 },119 }120 );121}122```123124This is the workhorse. Always use this in Server Components, Server Actions, and Route Handlers. The `cookies()` call makes it per-request.125126### Admin Client (Service Role — bypasses RLS)127128```typescript129// src/lib/supabase/admin.ts130import { createClient } from "@supabase/supabase-js";131import type { Database } from "@/lib/database.types";132133export const supabaseAdmin = createClient<Database>(134 process.env.NEXT_PUBLIC_SUPABASE_URL!,135 process.env.SUPABASE_SERVICE_ROLE_KEY!136);137```138139**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.140141### Middleware (Session Refresh)142143The 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.144145## Auth Patterns146147### Protecting Routes (Server Component)148149```typescript150// src/app/(dashboard)/layout.tsx151import { redirect } from "next/navigation";152import { createClient } from "@/lib/supabase/server";153154export default async function DashboardLayout({ children }: { children: React.ReactNode }) {155 const supabase = await createClient();156 const { data: { user } } = await supabase.auth.getUser();157158 if (!user) redirect("/login");159160 return <>{children}</>;161}162```163164**Always use `getUser()`**, not `getSession()`. `getUser()` validates the JWT against Supabase Auth; `getSession()` only reads the local token and can be spoofed.165166### Sign In / Sign Out (Server Actions)167168```typescript169"use server";170import { createClient } from "@/lib/supabase/server";171import { redirect } from "next/navigation";172173export 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```183184For 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.185186## Row Level Security (RLS)187188RLS is non-negotiable. Every table with user data must have policies. The anon key is **public** — RLS is the only thing preventing unauthorized access.189190### Example: Projects Table191192```sql193-- supabase/migrations/20240102000000_create_projects.sql194create 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 null201);202203alter table public.projects enable row level security;204205-- Users can only see their own projects206create policy "Users can view own projects"207 on public.projects for select208 using (auth.uid() = user_id);209210-- Users can only insert projects as themselves211create policy "Users can create own projects"212 on public.projects for insert213 with check (auth.uid() = user_id);214215-- Users can only update their own projects216create policy "Users can update own projects"217 on public.projects for update218 using (auth.uid() = user_id)219 with check (auth.uid() = user_id);220221-- Users can only delete their own projects222create policy "Users can delete own projects"223 on public.projects for delete224 using (auth.uid() = user_id);225```226227**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.233234## Server Actions + Supabase235236```typescript237// src/app/(dashboard)/projects/actions.ts238"use server";239240import { revalidatePath } from "next/cache";241import { createClient } from "@/lib/supabase/server";242import type { Database } from "@/lib/database.types";243244type ProjectInsert = Database["public"]["Tables"]["projects"]["Insert"];245246export 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");250251 const name = formData.get("name") as string;252 if (!name || name.length > 100) throw new Error("Invalid project name");253254 const { error } = await supabase.from("projects").insert({255 name,256 user_id: user.id,257 } satisfies ProjectInsert);258259 if (error) throw new Error("Failed to create project");260 revalidatePath("/projects");261}262263export async function deleteProject(projectId: string) {264 const supabase = await createClient();265 const { error } = await supabase.from("projects").delete().eq("id", projectId);266267 if (error) throw new Error("Failed to delete project");268 revalidatePath("/projects");269}270```271272**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.277278## Data Fetching in Server Components279280```typescript281// src/app/(dashboard)/projects/page.tsx282import { createClient } from "@/lib/supabase/server";283284export default async function ProjectsPage() {285 const supabase = await createClient();286 const { data: projects, error } = await supabase287 .from("projects")288 .select("id, name, description, created_at")289 .order("created_at", { ascending: false });290291 if (error) throw error;292293 return (294 <div>295 <h1>Projects</h1>296 {projects.map((project) => (297 <ProjectCard key={project.id} project={project} />298 ))}299 </div>300 );301}302```303304- 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.307308## Type Generation309310Run type generation after any migration:311312```bash313pnpm supabase gen types typescript --project-id $PROJECT_ID > src/lib/database.types.ts314```315316For local development:317318```bash319pnpm supabase gen types typescript --local > src/lib/database.types.ts320```321322- **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:325326```typescript327// src/lib/database.types.ts (add at the bottom — or in a separate helpers file)328import type { Database } from "./database.types";329330export 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```335336## Exploring the Codebase337338```bash339# Find all Supabase client usages340rg "createClient|createBrowserClient|createServerClient" src/341342# Find all RLS policies343rg "create policy" supabase/migrations/344345# Find all Server Actions346rg '"use server"' src/app/347348# Find route handlers349find src/app -name "route.ts" -o -name "route.tsx"350351# Check the Supabase schema352cat supabase/migrations/*.sql | head -200353354# Find all revalidation calls355rg "revalidatePath|revalidateTag" src/356357# Check middleware358cat src/middleware.ts359360# Find environment variables in use361rg "process\.env\." src/lib/362363# Check which tables exist364rg "create table" supabase/migrations/365366# Find all page components367find src/app -name "page.tsx"368```369370## When to Ask vs. Proceed371372**Just do it:**373- Adding a new page that follows existing patterns374- Creating a new Server Action for CRUD operations375- Adding a Supabase migration for a new table with RLS policies376- Writing or updating tests377- Adding a new component in `src/components/`378- Fixing lint or type errors379- Regenerating Supabase types after a migration380381**Ask first:**382- Modifying the auth flow or middleware383- Changing the Supabase client setup (cookie handling, client creation)384- Adding or modifying RLS policies on existing tables385- Adding a new third-party dependency386- Changing the database schema for an existing table (especially destructive changes)387- Setting up realtime subscriptions or Supabase Edge Functions388- Anything involving the service role key or admin client389390## Git Workflow391392- Branch from `main`. Name: `feat/<thing>`, `fix/<thing>`, `chore/<thing>`.393- Commit migrations separately from application code.394- Before pushing:395 ```bash396 pnpm lint397 pnpm typecheck398 pnpm test399 pnpm build # Catches SSR errors that dev mode misses400 ```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.403404## Common Pitfalls405406- **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: