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.
| Concern | Primary tool | Failure mode |
|---|---|---|
| Feature isolation | Route groups, colocated modules | Circular imports, shared god-utils |
| Server/client split | RSC + 'use client' leaves | Accidental client boundary at root |
| Data freshness | Tags, revalidatePath, ISR/PPR | Stale UI after mutations |
| Multi-team scale | Module boundaries, optional federation | One deploy blocks all teams |
| Observability | Traces, RUM, structured logs | Debugging 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 onlyAccidental 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 /> insideInterview: 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/APISession 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 driversImport 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 callbackInterview: 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 logsMiddleware 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.revalidatePathbusts 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 fetchSenior 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()