Pick a Rendering Strategy for a Next.js Route

Naseebullah Ahmadi  Senior Software Engineer, London

An App Router route is prerendered by default and silently turns dynamic the moment you read something request-specific. Knowing which bucket a route belongs in (and what forces it out) is the difference between an instant page and a server render on every hit.

7 min read
#engineering
In one line

Two questions decide it. Does the HTML differ per request? How fresh must it be? Same for everyone, can be stale → static. Same for everyone, must be current → static + revalidate (ISR). Differs per user/request → dynamic. Needs the browser → a 'use client' island inside whichever of those.

You're here when

Signs this is your situation
  • A new route, and you're choosing between "just render it", generateStaticParams, revalidate, or 'use client'
  • A page that should be instant is doing a server round-trip on every request
  • next build marks a route ƒ (Dynamic) when you expected (Static)
  • You added cookies(), headers(), or searchParams and something got slow

The play

  1. 1Start from the default: a route is prerendered at build time unless something opts it out. You don't add config to be static: you already are.
  2. 2Does the HTML differ per request? If no, keep it static. If it's static with per-URL params (a slug), list them from generateStaticParams; the build marks it (SSG).
  3. 3Must it refresh without a redeploy? Add export const revalidate = <seconds> (or fetch(url, { next: { revalidate } }) per call). Still served static, rebuilt in the background: that's ISR.
  4. 4Does it genuinely differ per user or request (auth, geo, a searchParams that changes the body)? Then let it be dynamic: reading cookies() / headers() / dynamic searchParams opts it in, or set export const dynamic = 'force-dynamic' to be explicit.
  5. 5Anything that needs the browser (onClick, useState, window, a socket) goes in a 'use client' component. Keep the route's shell on whichever server strategy above; don't make the page a client component to get one button.
  6. 6Check next build: static, SSG, ƒ dynamic. If a route flipped to ƒ you didn't expect, find the request-time API that did it.
@itsnas static.tsx
codestatic.tsx
// ○ Static: rendered once at build, same HTML for every visitor
export default async function Page() {
  const posts = await getPosts()
  return <List posts={posts} />
}
main
Nas (@itsnas)

Which path

Same for every visitor

  • Static. Add revalidate only if it must refresh without a redeploy.
  • Never reach for force-dynamic here: you'd trade an instant CDN hit for a server render to get nothing.

Differs per visitor or request

  • Dynamic, but keep the dynamic part small.
  • A static shell with one dynamic slot (or a client component fetching after load) beats making the whole route SSR.

Gotchas

  1. 1

    force-dynamic to 'fix' stale data

    That makes every hit a server render. If you only need freshness, revalidate keeps the route static and fast.

  2. 2

    One cookies() call in a shared layout

    It opts every route under that layout into dynamic rendering. Read request APIs as deep in the tree as possible, or in a client component.

  3. 3

    'use client' at the top of the page file

    It doesn't make "a client page": it makes that file and everything it imports client-side, so the bundle grows. Push the boundary down to the smallest interactive piece.

  4. 4

    Assuming searchParams is free

    Reading searchParams in a Server Component marks the route dynamic. If the value set is small and known, put it in the path and use generateStaticParams instead.

  5. 5

    dynamic and revalidate fighting

    force-dynamic wins and silently disables revalidate. Pick one model per route.

Confirm you're clear

  1. 1next build shows the symbol you intended ( / / ƒ) for the route.
  2. 2Nothing request-specific is read above the point where dynamic rendering is actually needed.
  3. 3Client bundles carry only the components that truly need the browser.