Skip to content

Next.js App Router: Production-Grade Field Guide

Core Principle

Default to the server, opt into the client at the leaves. Keep Server Components as the default; push 'use client' to the smallest interactive leaf; fetch data on the server (parallelized with Promise.all); stream with Suspense; treat every Server Action and Route Handler as a public, unauthenticated endpoint that must independently validate input (Zod) and re-check authorization.

Version Reality (as of June 2026)

Version Released Key change
v14 fetch cached by default; experimental PPR
v15 Oct 21, 2024 fetch uncached by default; cache opt-in required
v16 Oct 21, 2025 Cache Components stable (cacheComponents: true, use cache); PPR is now default behavior when enabled; React Compiler 1.0 stable but opt-in; middleware.ts renamed to proxy.ts (Node.js runtime)

This is the #1 source of cache bugs. Before debugging caching issues, always identify which major version the codebase is on.


Architecture and Project Structure

Key Rule: app/ is for Routing Only

Keep page.tsx/layout.tsx thin. Move logic into a feature/domain structure:

src/
  app/              # routing only — thin page/layout files
  features/         # domain logic: auth/, products/, checkout/
  server/           # DAL, DB queries, service functions
  lib/              # shared pure utilities
  components/       # genuinely shared UI

Why not layer-based buckets (components/, hooks/, utils/)? They read well early but degrade into high-fan-in dependency magnets at scale. Feature-based structure keeps a feature's code together and makes it testable and deletable.

Colocation

Folders in app/ are not routable until a page/route file exists. Safely colocate components, tests, and utilities inside route segments.

  • _folderName — private folder excluded from routing
  • (groupName) — route group: organizes without affecting URL; enables multiple root layouts

Data Access Layer (DAL)

Establish a DAL from day one — a service/repository layer that: - Is the only place that touches the database - Re-verifies auth/authorization before every read or mutation - Is independently testable (pure functions, no Next.js coupling) - Is reusable across Server Actions, Route Handlers, webhooks, and cron jobs

Putting business logic directly in Server Actions makes it untestable; extract pure service functions.

Monorepo (Turborepo)

Turborepo is the default (maintained by Vercel). Critical configuration: - Set outputs in turbo.json or every task rebuilds from scratch - Build shared packages before dependent apps - Use transpilePackages for internal packages - Use output: 'standalone' + correct outputFileTracingRoot for deploying a single app

Barrel exports: Large index.ts barrels hurt dev compile time and tree-shaking. experimental.optimizePackageImports auto-rewrites named imports for known libraries (lucide-react, @mui/material) — but does NOT reliably work for internal workspace packages with Turbopack/symlinks (pnpm). For your own barrel files, refactor to direct imports.


App Router Rendering Patterns

Server vs. Client Decision Framework

Use Server Components (default) for: - Data fetching - Heavy/secret-bearing dependencies (DB, SDKs, API keys) - Large lists, markdown/MDX - Composition without interactivity

Use Client Components ('use client') only for: - Interactivity (onClick, useState, useEffect) - Browser APIs (window, localStorage, navigator) - Animations, focus management

Common mistakes: - Putting 'use client' high in the tree — promotes whole subtree, ships unnecessary JS, kills streaming/SEO - Reading cookies()/headers() in layouts — forces the whole route dynamic, disables static/PPR - Fetching with useEffect instead of server fetch — delays render, hurts LCP/SEO, creates waterfalls

Passing Server Components into Client Components

To avoid promoting whole subtrees to the client, pass Server Components as children:

// DO: server component renders and passes as children prop
<ClientWrapper>{/* server component here */}</ClientWrapper>

// DON'T: importing a server component inside a client component
// (silently promotes it to client)

Layouts, Parallel Routes, and Intercepting Routes

Layouts: Cannot pass data to children via props. Each segment that needs data fetches it independently — fetch memoization makes this cheap.

Parallel routes (@slot): Render multiple independent segments in one layout. Real uses: - Dashboards with independent streaming/error/loading per pane - Role-based conditional rendering (the admin slot is never sent to non-admin browsers) - Every slot needs a default.tsx for hard navigations or the app errors

Intercepting routes ((.), (..), (...)): Combined with parallel routes, the canonical use is modals with shareable URLs — Instagram-style photo overlays, login modals with a standalone /login page. Known gotchas: - (..) counts route segments, not filesystem levels (@slot folders are skipped) - Multiple parallel slots can show multiple modals - Modals can persist on parent navigation without a catch-all/conditional

Loading UI and Error Boundaries

  • loading.tsx auto-wraps a route in Suspense with the file's content as fallback
  • Place Suspense boundaries around distinct data-loading subtrees with dimension-matched skeleton fallbacks (avoids CLS)
  • Too-coarse boundaries reintroduce waterfalls; too-fine add overhead

error.tsx must be a Client Component:

'use client'
export default function Error({ error, reset }: { error: Error; reset: () => void }) { ... }

global-error.tsx is the root fallback — must render its own <html> and <body>, and cannot use providers above it.


Data Fetching Patterns

Caching and Revalidation

In v15+, fetch is uncached by default. To cache: - fetch(url, { cache: 'force-cache' }) — permanent until revalidated - fetch(url, { next: { revalidate: 60 } }) — time-based ISR - export const revalidate = 60 — segment-level time-based

Invalidation strategies: - revalidatePath('/path') — invalidate all data behind a URL (start here, easier to reason about) - revalidateTag('tag') — fine-grained, invalidates data behind multiple URLs sharing a tag

In v16 Cache Components (use cache): - use cache directive + cacheLife() + cacheTag() extends tagging beyond fetch to any async work (DB queries, filesystem reads) - updateTag() is Server-Action-only for read-your-own-writes

Parallelizing Fetches (Avoiding Waterfalls)

// BAD: sequential — each waits for the previous
const user = await getUser(id)
const posts = await getPosts(id)

// GOOD: parallel — both fire at once
const [user, posts] = await Promise.all([getUser(id), getPosts(id)])
// Use Promise.allSettled for partial-failure tolerance

Preload pattern — start a fetch early before a blocking call:

void preloadItem(id)  // fire-and-forget, starts fetching immediately
const critical = await getCriticalData()

React cache() for Deduplication

import { cache } from 'react'

export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } })
})
// Called anywhere in the render tree — executes only once per request

Use the server-only package to prevent accidental client import of DB-touching functions.

React Query / SWR with App Router

Still valuable for: client-side caching, optimistic updates, polling/real-time, and direct cache manipulation. Pattern: fetch initial data on the server, hydrate, then use TanStack Query client-side for live updates.


Mutations: Server Actions

Server Actions compile to public POST endpoints. Built-in protections: Origin/Host comparison, POST-only, encrypted non-deterministic action IDs, dead-code elimination.

You still must: 1. Validate every input with Zod (safeParse, never parse) 2. Re-check auth and authorization (IDOR/ownership — don't trust the user's claimed ID) 3. Rate-limit expensive/auth endpoints 4. Avoid leaking secrets through closures (move actions to separate files)

import { createSafeActionClient } from 'next-safe-action'
import { z } from 'zod'

const action = createSafeActionClient()
  .inputSchema(z.object({ id: z.string().cuid() }))
  .action(async ({ parsedInput, ctx }) => {
    // input is already validated; ctx has auth from .use() middleware
    return await updateItem(parsedInput.id)
  })

next-safe-action provides: composable .use() middleware (auth, rate-limit), useAction/useOptimisticAction hooks, Standard Schema support (Zod, Valibot, ArkType). zsa is an alternative.

Form Hooks (React 19)

  • useActionState — wraps a Server Action with state (pending, error, data)
  • useFormStatus — gives pending state to submit buttons inside a form
  • useOptimistic — instant UI feedback while action confirms

State Management

Decision Framework

State type Best tool
URL/shareable (filters, pagination, search) nuqs (parseAsInteger, parseAsArrayOf)
Server state Keep on server; React cache() + Server Components
Global client UI state Zustand (single store) or Jotai (atomic/derived)
Form state react-hook-form + zodResolver
Context (theme, locale) React Context in a Client Component provider

Critical App Router rule: Never create a global store at module level on the server — it leaks state across requests. Create per-request stores via provider patterns.

nuqs Caveats

  • Not for large/private objects
  • Frequent URL updates can cause perf issues — use debouncing/limitUrlUpdates
  • Does not replace a global client store

Context Pitfalls

Context providers must be Client Components, don't cross the server/client boundary, and re-render all consumers (performance trap). Use for low-frequency values only.


Authentication

Auth.js v5

Single auth.ts config exports { auth, handlers, signIn, signOut }. Universal auth() works in Server Components, Route Handlers, and middleware.

Split config for edge compatibility: - auth.config.ts — edge-safe, no DB adapter; used in middleware for JWT verification - auth.ts — Node.js runtime, with DB adapter for session storage

Sessions: JWT vs. Database

Strategy Pros Cons
JWT Stateless, edge-verifiable, fast Hard to revoke
Database Revocable, single source of truth DB call per request; edge can't reach most DBs

Common middle ground: short-lived JWT (~15 min) + refresh token in DB.

Always store session tokens in httpOnly, secure, sameSite cookies — never localStorage (XSS).

Defense in Depth: Three Layers

  1. Middleware/proxy — optimistic route filtering (fast, edge); NOT a security boundary (CVE-2025-29927)
  2. Server Components / Route Handlers — verify for data access
  3. Server Actions — verify before every mutation

UI role checks are UX, not security. Include API routes in the middleware matcher (common bug: protecting /dashboard but leaving /api/dashboard/* open).


TypeScript Patterns

Zod: Runtime Validation Backbone

TypeScript types are erased at runtime. userId: string does not stop {"userId": {"$ne": null}}.

const schema = z.object({ userId: z.string().cuid() })
const result = schema.safeParse(input)
if (!result.success) return { error: result.error.flatten().fieldErrors }
const { userId } = result.data

Validate every Server Action and Route Handler input. Infer types with z.infer<typeof schema>.

Typed Routes

Enable typedRoutes in next.config.ts to catch invalid <Link href> at compile time.

tRPC

Choose tRPC (T3 stack) when you want end-to-end typed RPC across a separate client. tRPC v11 (2025) integrates with RSC — call procedures directly in Server Components. create-t3-app scaffolds App Router by default.


Testing Patterns

Layer Tool Notes
Unit (sync Server/Client Components, Server Actions as plain functions, Zod schemas) Vitest + React Testing Library Vitest cannot render async Server Components — push those to E2E
E2E (async RSC, auth flows, checkout, cookies/middleware) Playwright Configure webServer; preferred over Cypress
Mocking Vitest mocks for next/navigation, next/headers Mocky Balboa for server-side network mocking in Playwright

Styling

Compatibility Matrix

Approach RSC-compatible Runtime cost Notes
Tailwind CSS Yes Zero Default for new App Router projects; foundation for shadcn/ui
CSS Modules Yes Zero Built-in, scoped, zero-config
vanilla-extract / Panda CSS / StyleX Yes Zero Zero-runtime CSS-in-JS
styled-components / Emotion No (requires 'use client' boundary and registry) Runtime Inherent perf trade-offs in App Router

Migration advice: Don't rip out a working styled-components codebase wholesale. Adopt Tailwind for new components, or move to zero-runtime. Dark mode: next-themes with class-based Tailwind dark:.


Performance Optimization

Core Primitives

Tool What it does Critical detail
next/image Automatic WebP/AVIF, lazy loading, responsive srcset Set priority on LCP image; always provide width/height
next/font Self-hosts fonts at build time; zero runtime network request Eliminates layout shift + external DNS; display: 'swap' built-in
next/script Controls third-party script loading strategy afterInteractive, lazyOnload, experimental worker
next/dynamic Code splitting for heavy components ssr: false for client-only libs
@next/bundle-analyzer Visualize bundle composition Run with ANALYZE=true; set CI size budgets

Common bundle culprits: moment.js (→ date-fns/dayjs), full lodash (→ lodash-es or per-method), full icon libraries (→ optimizePackageImports).

PPR / Cache Components

Mental model: everything outside <Suspense> is static, everything inside is dynamic. The static shell is served from the edge (TTFB ~40–90ms), dynamic holes stream in one HTTP response.

Best for: Pages with a stable shell and small dynamic regions (product pages, pricing, marketing surfaces with ~80% cacheable layout, ~20% per-user data).

Skip PPR for: 100%-personalized pages (account settings, live dashboards), fully authenticated apps (generic shell has little CDN value).

Debugging: a single cookies()/headers()/connection() call outside Suspense makes the route fully dynamic. Use NEXT_LOG_LEVEL=debug next build to print why a route is dynamic.

Turbopack (Default in v16)

  • Dev: dramatically faster HMR vs Webpack
  • Production: one controlled test (Cal.com) showed ~19% faster median cold build but ~211KB larger shared chunk, +279KB median First Load JS per route — Turbopack tree-shaking is still maturing
  • Measure First Load JS before switching production builds
  • Hybrid (Turbopack dev, Webpack prod) is a valid fallback
  • Some custom Webpack plugins and Sass custom functions aren't supported

Security

CVE-2025-29927 (CVSS 9.1) — Middleware Auth Bypass

The x-middleware-subrequest header could bypass all middleware-based authorization. Published March 21, 2025; reported by Rachid Allam.

  • Patched in: 12.3.5, 13.5.9, 14.2.25, 15.2.3
  • Not affected: Vercel-hosted deployments
  • At risk: Self-hosted (next start)
  • Architectural lesson: Middleware is not a security boundary — auth must live in the DAL

May 2026 Security Release (13 advisories)

Published May 6–7, 2026 — covering auth bypass, SSRF, cache poisoning, XSS, RSC denial-of-service (CVE-2026-23870).

  • Original patches (15.5.16 / 16.2.5) were superseded after an incomplete fix
  • Pin to: 15.5.18 / 16.2.6 for Turbopack users
  • SSRF advisory (GHSA-c4j6-fc7j-m34r) affects only self-hosted deployments

Rate Limiting

@upstash/ratelimit + Upstash Redis (sliding window) is the dominant pattern.

Key rules: - A global 100 req/min cap is not endpoint security — set low, specific thresholds on the right assets - In-memory Maps don't survive edge instances/redeploys; use external Redis - Apply to: login, OTP, password reset, expensive/AI endpoints

Input Validation and SSRF Prevention

  • Validate all inputs server-side with Zod
  • For SSRF: restrict/allowlist server-side fetch targets
  • Validate returnTo/redirect params to relative URLs only (open-redirect/phishing prevention)

Secrets

  • Never expose via NEXT_PUBLIC_ env vars (these are baked into the client bundle at build time)
  • Use the server-only package to prevent accidental client import
  • Use React taint APIs (taintObjectReference, taintUniqueValue) to prevent passing sensitive objects to Client Components
  • Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY for consistent keys across instances

Deployment

Vercel vs. Self-Hosting

Factor Vercel Self-hosted (Docker/K8s)
Setup Zero-config You own CI/CD, cache warming, health probes
ISR Distributed automatically Cache lives in .next/cache, not durable without custom cache handler
Image optimization Automatic Requires sharp installed
Version skew protection Built-in Manual
Cost Significant above ~10M requests/month Predictable

Self-Hosting Configuration

# output: 'standalone' traces exact deps into a minimal Node server
# Copy public/ and .next/static separately

Critical self-hosting checklist: - Disable reverse-proxy buffering for streaming: proxy_buffering off, X-Accel-Buffering no, HTTP/1.1 - NEXT_PUBLIC_ vars are build-time — need rebuild or separate images per environment - Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY for multi-instance consistency - Separate liveness/readiness probes (DB failures → affect readiness, not liveness)

Output Modes

  • output: 'standalone' — minimal Node server (~200MB image vs 1GB+); required for clean Docker/K8s
  • output: 'export' — fully static (replaces old next export)

Anti-Patterns

Anti-pattern Why it's wrong
Over-using 'use client' Promotes subtrees, bloats bundles, breaks streaming/SEO
useEffect for data that could be server-fetched Delays render, hurts LCP/SEO, creates waterfalls
Reading cookies()/headers() in layouts Forces entire route dynamic, disables static/PPR
N+1 queries Fetch in a loop; batch with Promise.all/DataLoader
Trusting page-level auth for Server Actions Actions are separate public endpoints; re-verify each time
Trusting TypeScript types at runtime Always validate with Zod
Module-level global stores on the server Leaks state across requests
Fine-grained revalidatePath on everything Over-invalidation busts unrelated caches; use tags
Assuming v14 caching in a v15/v16 codebase fetch is no longer cached by default

Staged Setup Recommendations

Stage 1 — Foundation (any new project): create-next-app (or create-t3-app for typed RPC + Prisma/Drizzle + Auth.js) on App Router, src/ directory, TypeScript strict, Tailwind, ESLint. Feature-based structure from day one; establish a DAL.

Stage 2 — Data and mutations: Server Actions with next-safe-action + Zod; react-hook-form + zodResolver on client; useActionState/useOptimistic for UX. Decide caching explicitly.

Stage 3 — Auth and security hardening (before launch): Auth.js v5 with split edge/Node config; JWT + DB refresh token. Verify auth in middleware AND DAL AND every Server Action. Add @upstash/ratelimit to auth and expensive endpoints. Pin Next.js to a patched version.

Stage 4 — Performance and scale: next/image, next/font, next/script, next/dynamic; bundle analysis with CI budgets; PPR/Cache Components for mixed static+dynamic pages. Target: LCP <2.5s, INP <200ms, CLS <0.1.

Stage 5 — Deployment decision: Default Vercel for time-to-market. Re-evaluate self-hosting at ~10M+ requests/month, compliance/data-residency needs, or existing K8s platform.


Plugin: frontend-design · View SKILL.md on GitHub