Architecting a Real Next.js App: Boundaries & Structure
A toy app collapses everything into app/page.tsx and a pile of route handlers. That works until two things happen at once: the codebase grows past what one person holds in their head, and you let Claude Code drive larger changes. Both fail for the same reason — no boundaries. The leap from project to architecture is deciding where the seams are, then making them enforceable so neither you nor an agent can accidentally cross them.
The mental model: layers that only point one way
Think in three concentric rings. The outside is your delivery layer (App Router routes, Server Components, Route Handlers). The middle is your domain layer — pure TypeScript functions that own business rules and know nothing about HTTP or React. The inside is your data layer — the only place that talks to Supabase. Dependencies point inward only: a route may call the domain, the domain may call data, but data never imports a React component. When that rule holds, you can test the middle without a browser and swap the edges without rewriting the core.
The structural mistake that kills Next.js apps is leaking the Supabase client everywhere. The moment a Server Component runs a raw query, your business logic is welded to your transport. Put one seam between them:
// lib/domain/enrollments.ts — knows nothing about Next.js or HTTP
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/types";
export async function enrollUser(
db: SupabaseClient<Database>,
userId: string,
courseId: string,
) {
const { data: existing } = await db
.from("enrollments")
.select("id")
.eq("user_id", userId)
.eq("course_id", courseId)
.maybeSingle();
if (existing) return { ok: false as const, reason: "already_enrolled" };
const { data, error } = await db
.from("enrollments")
.insert({ user_id: userId, course_id: courseId })
.select()
.single();
if (error) throw error;
return { ok: true as const, enrollment: data };
}
The Server Component or Server Action stays thin — it builds the request-scoped client, checks auth, and delegates:
// app/courses/[id]/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { enrollUser } from "@/lib/domain/enrollments";
export async function enroll(courseId: string) {
const db = await createClient();
const { data: { user } } = await db.auth.getUser();
if (!user) throw new Error("unauthenticated");
return enrollUser(db, user.id, courseId);
}
Making boundaries real, not aspirational
A folder convention nobody enforces decays in a week. Three cheap guardrails keep it honest:
- Path aliases as intent. Map
@/lib/domain/*and@/lib/supabase/*intsconfig.jsonso imports read like architecture, not relative-path spaghetti. - A lint rule against leaks. Use
eslint-plugin-boundariesor ano-restricted-importsrule forbidding@supabase/*imports insidelib/domain. Now the layering is checked in CI, not just in your memory. - A
CLAUDE.mdthat names the rule. State plainly: "Domain functions take adbargument; they never import fromapp/or callcreateClient." Claude Code reads this on every run and will respect the seam instead of inventing a shortcut.
Pitfalls at senior depth
The Supabase client is request-scoped on the server — never hoist it to a module-level singleton, or you'll bleed one user's auth context into another's request. Keep Server and Client Component boundaries deliberate: a "use client" at the top of a layout drags the whole subtree onto the client and silently kills your server-side data access. And resist the urge to share Zod schemas by importing them up from app/ into the domain — validation contracts belong in the domain and flow outward, so both your Server Action and a future cron job validate identically.
⌨️ Exercise
Take one feature in your app that currently runs a Supabase query directly inside a Server Component or Route Handler. Extract the business logic into a lib/domain/ function that accepts the db client as its first argument and returns a typed result. Then add a no-restricted-imports ESLint rule blocking @supabase/* inside lib/domain, run eslint ., and confirm it fails if you reintroduce the import. Commit the seam plus the rule together.