Topics
v5.0.0
On this page · sections

Next.js Architecture & System Design

Large Next.js applications need explicit boundaries — between server and client, between features, between cache layers, and between teams. The App Router encourages colocation, but without discipline, every page becomes a client bundle, every layout reads cookies(), and cache invalidation becomes tribal knowledge.

ConcernPrimary toolFailure mode
Feature isolationRoute groups, colocated modulesCircular imports, shared god-utils
Server/client splitRSC + 'use client' leavesAccidental client boundary at root
Data freshnessTags, revalidatePath, ISR/PPRStale UI after mutations
Multi-team scaleModule boundaries, optional federationOne deploy blocks all teams
ObservabilityTraces, RUM, structured logsDebugging RSC vs client bugs

Feature architecture in the App Router

Definition

Organize by feature, not by file type. Each feature owns its routes, Server Components, Client islands, actions, and types.

src/ app/ (marketing)/ → route group — shared marketing layout page.tsx pricing/page.tsx (app)/ → authenticated app shell dashboard/ page.tsx _components/ → private to dashboard route actions.ts api/ webhook/route.ts features/ billing/ → cross-route feature module components/ actions.ts queries.ts lib/ → truly shared infra (db, auth, env)

Route groups (folder) do not affect the URL — they share layouts without nesting paths.

// app/(app)/layout.tsx — auth shell for all app routes import { redirect } from "next/navigation"; import { getServerSession } from "@/lib/auth"; export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await getServerSession(); if (!session) redirect("/login"); return <AppShell user={session.user}>{children}</AppShell>; }

Interview: Colocate by feature route. lib/ is for infra, not a dumping ground for every component.

RSC boundaries and composition

Definition

Push 'use client' to leaves — buttons, forms, charts. Server Components fetch data and pass serializable props down.

// features/posts/PostPage.tsx — Server Component import { CommentThread } from "./CommentThread"; // 'use client' export default async function PostPage({ id }: { id: string }) { const post = await getPost(id); // server fetch, cached return ( <article> <h1>{post.title}</h1> <div>{post.body}</div> <CommentThread postId={id} initialCount={post.commentCount} /> </article> ); }

Boundary rules:

Server → Client → pass serializable props (no functions unless Server Action) Client → Server → import Server Components as children/slots only cookies/headers → Server Components only useState/useEffect → Client Components only

Accidental client root — importing a Client Component from the root layout pulls hydration across the tree:

// Bad — ClientNav in root layout forces client boundary concerns upward import { ClientNav } from "./ClientNav"; // 'use client' at top of file // Better — Server layout wraps client leaf import { Nav } from "./Nav"; // Server wrapper // Nav.tsx renders <ClientNavItems /> inside

Interview: The boundary is a serialization contract. Every 'use client' file is a potential bundle and hydration cost.

Caching strategy at scale

Definition

Map each data type to a cache layer and invalidation trigger:

Marketing CMS pages → Full Route Cache + ISR (revalidate: 3600) Product catalog → Data Cache + tags ['products', 'product-{id}'] User dashboard → dynamic (cookies) + no Full Route Cache Mutation → revalidateTag + revalidatePath in Server Action Client soft navigation → Router Cache (session-scoped)

Tag design — hierarchical and predictable:

// lib/cache-tags.ts export const tags = { products: "products", product: (id: string) => `product-${id}`, user: (id: string) => `user-${id}`, }; // fetch with tags await fetch(url, { next: { tags: [tags.product(id)] } }); // invalidate on mutation revalidateTag(tags.product(id)); revalidateTag(tags.products);

Document a cache matrix in the repo — route, data source, layer, TTL, invalidation event. Stale bugs come from undocumented assumptions.

Real-world example

E-commerce: product page ISR shell, cart always dynamic, inventory tag invalidated on webhook.

// app/products/[id]/page.tsx export const revalidate = 600; export default async function ProductPage(props: { params: Promise<{ id: string }> }) { const { id } = await props.params; const product = await getProduct(id); // tagged, ISR return ( <main> <ProductDetails product={product} /> <Suspense fallback={<Skeleton />}> <LiveInventory productId={id} /> {/* cache: 'no-store' */} </Suspense> <Suspense fallback={<Skeleton />}> <CartBadge /> {/* cookies() — dynamic */} </Suspense> </main> ); } // app/api/inventory-webhook/route.ts export async function POST(req: Request) { const { productId } = await req.json(); revalidateTag(`inventory-${productId}`); return Response.json({ ok: true }); }

Interview: Four cache layers — know which one is stale. Tags scale better than path-only invalidation for shared data.

Auth architecture across layers

Definition

Layer auth responsibilities — do not duplicate or skip layers:

Middleware → session cookie exists? redirect to login RSC layout → getServerSession() — who is the user? Server Action → authorize mutation (role, ownership) Data access layer → row-level checks at DB/API

Session data flows server-only:

// Never: pass session token to Client Component // Good: pass { userId, name, roles } derived on server export default async function Page() { const session = await getServerSession(); return <Dashboard user={session.user} />; // serializable subset }

BFF pattern — Next.js as backend-for-frontend: Route Handlers and Server Actions call internal services, never expose internal APIs directly to the browser.

Interview: Auth in middleware alone is insufficient. Resource authorization lives in Actions and data layer.

Scalability and multi-team patterns

Definition

As teams grow, isolate deploy risk and bundle coupling:

Monorepo + single deploy → route groups per team, CODEOWNERS, strict import rules Module Federation → team-owned remotes, shared React singleton Separate services + BFF → Next aggregates; domain APIs stay independent Edge vs Node runtime → edge for middleware/light handlers; Node for DB drivers

Import linting prevents feature cross-contamination:

// .eslintrc — conceptual { "rules": { "no-restricted-imports": ["error", { "patterns": ["**/features/*/*", "!**/features/billing/*"] }] } }

Long-running work — do not block Server Actions:

Payment capture, PDF generation, bulk export → queue job (Inngest, BullMQ, SQS) → return 202 + poll or webhook callback

Interview: Server Actions are not a job queue. Offload long I/O to async workers.

Observability

Definition

RSC apps need server traces and client RUM — errors split across boundaries.

Server → OpenTelemetry / Vercel tracing on fetch + DB Client → error boundary + Sentry with source maps Web Vitals → useReportWebVitals → analytics pipeline Structured logs → request ID from middleware → correlate server logs

Middleware request ID injection:

// middleware.ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; export function middleware(request: NextRequest) { const requestId = crypto.randomUUID(); const response = NextResponse.next(); response.headers.set("x-request-id", requestId); return response; }
// app/error.tsx — route error boundary ('use client') "use client"; export default function Error({ error, reset }: { error: Error; reset: () => void }) { return ( <div> <h2>Something went wrong</h2> <button onClick={reset}>Try again</button> </div> ); }

Debug checklist for “stale UI”:

1. Which cache layer? (Data / Full Route / Router) 2. Was revalidateTag called after mutation? 3. Soft navigation vs hard reload? 4. dynamic = 'force-static' conflicting with cookies()?

Interview: router.refresh() busts Router Cache. revalidatePath busts Full Route + Data. Know which layer is stale.

System design interview scenarios

Definition

High-traffic marketing + personalized app — PPR for marketing shell, (app) route group with dynamic dashboard, edge middleware auth gate.

Multi-tenant SaaS — tenant ID from subdomain in middleware → rewrite to tenant-scoped routes; row-level security in data layer.

Content platform — ISR for public articles, on-demand revalidatePath on publish webhook, generateMetadata per article.

Real-time dashboard — Server Component for initial snapshot, Client island with WebSocket for ticks; do not poll from RSC.

Scenario → Pattern Marketing + login banner → PPR static shell + dynamic hole Tenant subdomain → middleware rewrite + tenant context Publish article → webhook → revalidateTag('articles') Live prices → Client island or dynamic hole with no-store fetch

Senior Interview Summary

Architecture

Feature colocation in app/ + features/. Route groups for layout sharing. lib/ for infra only.

RSC boundaries

Server by default; 'use client' at leaves. Serialization contract — no secrets or functions across boundary.

Caching

Document cache matrix. Tags for shared entities. Four layers — know which is stale.

Scale

Import boundaries, optional federation, job queue for long work. Edge for gates, Node for DB.

Observability

Request IDs, server traces, client error boundaries, Web Vitals RUM.

Best mental model

Feature → "Own routes, actions, components together." Boundary → "Server fetches; client interacts." Cache → "Tag + invalidate on write; matrix in docs." Auth → "Middleware gate + server verify + action authorize." Scale → "Queues for long work; federation for team isolation."

Practical rule

New feature → route group or features/ module Interactive widget → client leaf under server parent Shared product data → fetch tags + revalidateTag on mutation User-specific data → dynamic route or PPR hole Cross-team UI → module federation or strict package boundaries Debug stale UI → identify cache layer before adding router.refresh()

Last updated on