Next.js App Router Patterns
Architecture rules for Next.js applications built on the App Router, from the Server and Client Component split down to caching, mutations, and dev-server tuning. Every rule here is about where code runs and when its output goes stale.
Baseline: current stable Next.js on the App Router, meaning Next.js 16 or newer, where Turbopack is the default dev
bundler and params and searchParams arrive as promises.
When to activate
- Building or reviewing any route, layout, or component in an
app/directory. - Deciding whether a component belongs on the server or the client.
- Choosing a caching or revalidation strategy for fetched data.
- Adding a Server Action, a route handler, or per-route metadata.
- Migrating a Pages Router application to the App Router.
- Diagnosing a slow
next devstart or a slow hot update.
When not to activate
- Framework-agnostic React work: component composition, hooks, forms, animation. Use
react-patterns. - Visual direction, typography, and composition decisions. Use
frontend-design. - Token architecture and theming. Use
design-system. - Keyboard, focus, ARIA, and contrast requirements. Use
web-accessibility. - Titles, meta descriptions, structured data, and keyword mapping. Use
seo. - Profiling and Core Web Vitals remediation beyond the Next.js primitives. Use
performance-optimization.
Reference map
| Task | Open |
|---|---|
| Server and Client split, streaming, caching layers | references/rendering-and-data.md |
| File conventions, parallel and intercepting routes, route handlers, metadata | references/routing.md |
| Server Actions, form mutations, revalidation | references/server-actions.md |
| Images, bundle splitting, Turbopack, bundle analysis | references/performance-and-tooling.md |
Keep components on the server until interactivity forces otherwise
A Server Component ships no JavaScript to the browser and can reach the database directly. Add 'use client' only at
the leaf that needs state, an effect, or a DOM event handler, then keep that leaf small.
// PASS: server parent fetches, client leaf handles the click
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await db.product.findUnique({ where: { id } })
return <ProductDetail product={product}><AddToCartButton productId={id} /></ProductDetail>
}
// FAIL: the whole page becomes a client bundle for one button
'use client'
export default function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null)
useEffect(() => { fetch(`/api/products/${id}`).then(r => r.json()).then(setProduct) }, [id])
return <ProductDetail product={product} />
}
State the cache behaviour on every fetch
An unannotated fetch leaves the freshness of the page to a default that shifts between versions. Say what you mean: a revalidate window, a cache tag you can invalidate, or no store at all.
// PASS: the intent is readable at the call site
const res = await fetch(`${process.env.API_URL}/products`, { next: { revalidate: 3600, tags: ['products'] } })
// FAIL: nothing here says whether this page is static, ISR, or dynamic
const res = await fetch(`${process.env.API_URL}/products`)
Stream slow regions behind their own Suspense boundary
Blocking a whole route on its slowest query wastes the fast data. Render what you have, and let each slow region arrive on its own with a real skeleton.
// PASS: header renders immediately, reviews stream in
<ProductHeader product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={id} />
</Suspense>
// FAIL: the page waits for the slowest call before anything paints
const [product, reviews] = await Promise.all([getProduct(id), getReviews(id)])
return <ProductDetail product={product} reviews={reviews} />
Mutate through Server Actions, not client fetch calls
A Server Action runs on the server, works without client JavaScript, and invalidates its own caches inside the same
function. A client fetch to your own route handler gives up all three.
// PASS: mutation and invalidation live together
'use server'
export async function addToCart(productId: string) {
const parsed = addToCartSchema.parse({ productId })
await db.cart.create({ data: parsed })
revalidateTag('cart')
}
// FAIL: a client round trip that leaves the cache stale
const handleClick = () => fetch('/api/cart', { method: 'POST', body: JSON.stringify({ productId }) })
Validate and type every route handler boundary
A route handler is a public HTTP endpoint. Parse the body and the search params through a schema, and return a status code the caller can act on.
// PASS: parsed input, explicit status
export async function POST(request: NextRequest) {
const parsed = createProductSchema.safeParse(await request.json())
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
return NextResponse.json(await db.product.create({ data: parsed.data }), { status: 201 })
}
// FAIL: unvalidated body written straight to the database, always 200
export async function POST(request: NextRequest) {
return NextResponse.json(await db.product.create({ data: await request.json() }))
}
Run route handlers and pages on the Node.js runtime unless a specific deployment constraint says otherwise. It is the default and it supports the full API surface most database drivers and SDKs need.
Generate metadata from the same data the page renders
Hardcoded metadata in a dynamic route means every product shares one title. Derive it with generateMetadata, and
pre-render the known set with generateStaticParams.
// PASS: title and description follow the record
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await getProduct((await params).slug)
return { title: product.name, description: product.description }
}
// FAIL: one title for every product in the catalogue
export const metadata = { title: 'Product', description: 'A product page' }
Serve images through next/image and split heavy client modules
next/image handles format negotiation, responsive sizing, and lazy loading, and reserves layout space so the page
does not shift. Anything heavy and below the fold loads dynamically instead of riding in the first bundle.
// PASS: sized, prioritised above the fold, heavy chart deferred
<Image src={product.image} alt={product.name} width={1200} height={630} priority />
const Chart = dynamic(() => import('./RevenueChart'))
// FAIL: unsized raw tag plus a charting library in the entry bundle
<img src={product.image} />
import { RevenueChart } from './RevenueChart'
Develop on Turbopack
Turbopack is the default next dev bundler and keeps a filesystem cache, so a restart reuses previous work. Fall back
to webpack only to work around a specific bug, and record why.
next dev
next dev --webpack
The first command is the normal path. The second is the escape hatch, and a project that needs it permanently has a plugin problem worth fixing rather than a default worth changing.
Doc Comments
Default to none. A doc comment is usually a sign that the code failed to explain itself. Before writing one, extract the unclear block into a well-named function, rename the parameters so they carry their own meaning, and tighten the types. Do that first and most doc comments have nothing left to say, which is the outcome you want. Code that explains itself cannot go stale, a comment can.
When one is still genuinely needed, the prose is capped at five lines and is usually one. Every tag line is capped at
one line, @param and @returns and @throws alike, and only appears when it genuinely adds something: if the note
does not fit on a single line, shorten it or drop the tag. Four rules decide what goes in.
- Prose. One sentence saying what it does, then only what a caller cannot infer from the signature. Nothing more.
@paramonly when the name and the type do not already convey it, meaning units, nullability, a valid range, or who owns the argument afterwards.@param userId - The user identifieris noise, delete it, and never restate a type TypeScript already declares.@returnsonly when it is non-obvious.@throwsalways, for every error a caller can act on. TypeScript keeps throws out of the signature, so this one is genuinely contract rather than decoration.
Going past the five-line prose cap is allowed only when the contract genuinely cannot be stated in fewer lines, for example a documented state machine, an ordering requirement, or a concurrency guarantee. It is an exception you justify in review, not a budget to spend. The one-line cap on a tag line has no exception at all: shorten it or delete it.
// PASS: one sentence, then only what the signature cannot say
/**
* Creates a product and revalidates every cached listing that shows it.
*
* @throws {ZodError} when the submitted form fails validation
*/
export async function createProduct(formData: FormData): Promise<Product> { ... }
// FAIL: restates the signature and the file name
/**
* Server action that creates a product.
*
* @param formData - The form data
* @returns The created product
*/
export async function createProduct(formData: FormData): Promise<Product> { ... }
Related skills
react-patternsfor React composition, hooks, forms, and animation that do not depend on Next.js.frontend-designfor visual direction and composition.design-systemfor tokens, theming, and styling architecture.web-accessibilityfor keyboard, focus, ARIA, and contrast requirements.seofor titles, structured data, and keyword mapping behindgenerateMetadata.performance-optimizationfor profiling and Core Web Vitals work.api-designfor the contract shape of route handlers exposed to other clients.
Checklist
- Every
'use client'sits at the smallest leaf that needs it. - Every fetch declares a revalidate window, a cache tag, or
no-store. - Every slow region has a Suspense boundary with a real skeleton, and every route has
loading.tsxor an equivalent. - Every mutation is a Server Action that invalidates the caches it dirties.
- Every route handler parses its input through a schema and returns a meaningful status code.
- Every dynamic route derives its metadata from its own data.
- Every image goes through
next/imagewith explicit dimensions, and above-the-fold images setpriority. - Every heavy client-only module is loaded dynamically.
next devruns on Turbopack, with any webpack fallback justified in writing.- No doc comment restates a signature, and every recoverable error is documented with
@throws.