Skip to main content
← Back to course

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.

A left-to-right flow showing dependencies pointing inward: the delivery layer calls the domain layer, which calls the data layer.

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/* in tsconfig.json so imports read like architecture, not relative-path spaghetti.
  • A lint rule against leaks. Use eslint-plugin-boundaries or a no-restricted-imports rule forbidding @supabase/* imports inside lib/domain. Now the layering is checked in CI, not just in your memory.
  • A CLAUDE.md that names the rule. State plainly: "Domain functions take a db argument; they never import from app/ or call createClient." 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.