Skip to content
Shenzhen · The Greater Bay Area · Earth

Static by Default: Next.js App Router Rendering Strategies

In the App Router a route renders at build time until something in its tree touches request data, and the touch is usually one line inside a component the route does not own. This is how I pick a rendering mode per route, and how I make the decision fail the build when it changes.

9 min read1,944 words
Next.js ArchitectureWeb PerformanceNot yet translated.

Why should static be the default in the App Router?

In the App Router a route renders at build time until something in its tree touches request data, and the touch is usually one line inside a component the route does not own.

Static rendering is the App Router's default. A route that never reads the request is prerendered by next build, written to disk as HTML plus a flight payload, and served as a file. No React render happens per visitor, no function invocation is billed per request, and no data source is touched between a visitor and the first byte. The whole cost is paid once, in front of a deploy that a human approved.

That default is load-bearing and quietly fragile. One await headers() anywhere in the tree, including a layout three levels above the page or a helper that formats a date for the current timezone, changes the route's compute mode. The build still succeeds. The page still renders correctly. It simply renders per request from then on, and nothing in the output says "this used to be a file".

I treat static as the default that has to be argued away, not the optimisation that has to be argued for. The cheapest enforcement is to write the contract into the route file, because the reverse decision never announces itself:

// app/[locale]/blog/[slug]/page.tsx — the file that decides the rendering mode.
import { notFound } from 'next/navigation';
import { canonicalSlug, getPost, getPosts } from '@/lib/mdx';
import type { Locale, Post } from '@/content/types';

// Static is the default. `dynamic = 'error'` turns the default into a contract:
// if anything in this segment reaches for headers(), cookies(), draftMode(),
// connection() or the searchParams prop, the build fails and names the route.
export const dynamic = 'error';

// A slug outside the returned set 404s instead of rendering on demand.
export const dynamicParams = false;

export async function generateStaticParams(): Promise<{ locale: Locale; slug: string }[]> {
  return (['en', 'zh'] as const).flatMap((locale) =>
    getPosts(locale).map((post) => ({ locale, slug: canonicalSlug(post) })),
  );
}

// Shared by the page and by generateMetadata, which otherwise duplicate the
// lookup. `params` is a Promise in Next.js 15 and later; notFound() returns never.
export async function loadPost(params: Promise<{ locale: Locale; slug: string }>): Promise<Post> {
  const { locale, slug } = await params;
  const post = getPost(slug, locale);
  if (post === null) notFound();
  return post;
}

What actually converts a route to per-request rendering?

Six triggers, and exactly one of them is explicit enough to grep for.

TriggerWhat it changesWhere it usually hidesHow it shows up
await headers()marks the render as request-dependenta shared layout, an auth wrapper, a timezone helperthe route leaves the prerendered set and prints as ƒ
await cookies(), draftMode()sametheme switch, feature flags, an experiment bucket read on the serverevery page under that layout moves at once
the searchParams propthe page renders per distinct query string"just one filter" on a listing pagethe build passes, the route is ƒ, nobody is warned
fetch(url, { cache: 'no-store' })opts that request out of the data cachean SDK wrapper that defaults to no-storetime to first byte starts tracking upstream latency
await connection()waits for a live request before renderingadded to force a request-time timestampthe same cost, at least written down
export const dynamic = 'force-dynamic'the whole segment renders per requestcopied from an answer about a different problemthe only one a reviewer will notice

Four of the six are invisible in a diff. headers() is the one I would name first, because it always arrives with a plausible reason attached: the visitor's timezone, their country, the host they typed, whether they are signed in. Each of those has a cheaper form. A fixed build-time offset plus a client-side format gives correct local time on a static page. A country or experiment decision belongs in the proxy, where it can be turned into a URL instead of read inside a component. Authenticated state is the only one that genuinely cannot be static, and it usually belongs in a segment of its own rather than in a layout that everything shares.

Why does one headers() call in a shared layout cost more than the route it is in?

Because a layout's rendering mode is inherited by its whole subtree. app/[locale]/layout.tsx covers everything below it, so one await headers() there converts every page under it in a single line of diff.

The build I ran while writing this is the measurement. next build reported 70 prerendered pages in 4.8 seconds, and every page route in the route table carried (static) or (prerendered via generateStaticParams). The only request-time work in the project was the proxy, which appeared as ƒ Proxy (Middleware). A single header read in the locale layout would have moved all 70 pages to per-request rendering, and each would have printed as ƒ with no other signal that anything changed.

There is a second trap in the same place. Since Next.js 15, cookies(), headers() and draftMode() are asynchronous; code migrated from the synchronous versions was supposed to fail typecheck until it awaited them. That type error is the only warning this failure mode produces, and it is the one worth keeping strictly enabled.

The cost difference is arithmetic, not a benchmark. One prerendered English post in this repository is 137,842 bytes on disk and 25,024 bytes gzipped; serving it is a file read. Rendering it per request means one React render plus one content lookup per request. At 50,000 monthly page views that is 50,000 renders where one would have done, and the same HTML is rebuilt every time to produce bytes that were already correct.

Can a request-dependent decision stay static?

Yes, if the decision is expressed in the URL rather than read from the request during the render. This site does it for locale. Bare paths are rewritten onto the /en segment inside proxy.ts (Next.js 16's renamed middleware), so English owns the short URLs and a /zh prefix is served as written. No page component ever reads a header, which is why every locale variant is a real prerendered route and a crawler receives identical bytes for identical URLs.

Pricing by country is the harder case, and it is where the tempting shortcut is wrong. Rewriting /pricing to a per-currency path based on an edge geo header gives you the same URL with different content per visitor: either the CDN caches whichever currency arrived first for everyone, or you add Vary, which turns one cache entry into one per country. A redirect to a real per-currency URL is the honest version, because the URL changes and the destination stays prerendered:

// proxy.ts — the decision is turned into a URL, never a request-time render.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const CURRENCY_BY_COUNTRY: Readonly<Record<string, string>> = {
  CN: 'cny',
  DE: 'eur',
  SG: 'sgd',
  US: 'usd',
};

export function proxy(request: NextRequest): NextResponse {
  const url = request.nextUrl.clone();
  if (url.pathname !== '/pricing') return NextResponse.next();

  // The edge populates this header. It is request state, so it must be consumed
  // here: reading it inside a page is exactly what costs the route its static
  // rendering. Nothing downstream can see it.
  const country = request.headers.get('x-vercel-ip-country') ?? 'US';
  url.pathname = `/pricing/${CURRENCY_BY_COUNTRY[country] ?? 'usd'}`;

  // 307 rather than a rewrite: the URL has to change, or every visitor behind
  // the same cache key gets the first currency that happened to arrive.
  return NextResponse.redirect(url, 307);
}

export const config = { matcher: ['/pricing'] };

The tradeoff is URL duplication and one extra round trip for a visitor whose currency differs from the default. That is a real cost, and I take it, because the alternative is a page whose correctness depends on cache keys behaving.

Does streaming rescue a route that is already per-request?

No. Streaming changes when bytes arrive, not whether the render happens per request. A loading.tsx or an explicit Suspense boundary lets the shell flush while a slow subtree is still resolving, so time to first byte and first contentful paint stop waiting on the slowest query. The render count from the table above does not move: a dynamic route that streams is still one React render per request.

A shell only streams usefully if the shell itself can be built ahead of the request. Without that, the whole tree is per-request and streaming merely moves the wait from before the first byte to after it. Cache Components, the Next.js 16 flag that supersedes the earlier partial prerendering experiment, is what makes the split real: the shell is served from the build and only the holes are filled per request. If your largest contentful paint element sits inside a Suspense boundary, streaming improves the appearance of the page and leaves the metric that matters exactly where it was.

That flag also changes the failure mode this article is about, because uncached dynamic access outside a Suspense boundary becomes a build error instead of a silent drop out of the prerendered set. I have not enabled it on this site, since it changes caching semantics across the whole project and I would rather migrate route by route, but on new code it is the first guardrail I would turn on.

For a page that can be static, streaming is strictly worse than a file. A prerendered page is one disk read; a streamed dynamic page is a render, several flushes and a series of chunked writes. Nothing on this site needs a request, so it has no Suspense boundary at all. In client work where something genuinely does, the boundary is where I write down why, which turns the component tree into an inventory of the exceptions I argued for.

How do you make static the default instead of an intention?

Two mechanisms: a per-route contract that fails the build, and a CI assertion that catches the routes nobody remembered to annotate.

export const dynamic = 'error' is the loudest option available per file, and the one piece of route segment configuration worth standardising on. Put it in page files, not in a shared layout: the setting applies to the segment and everything below it, so a layout-level error fails every dynamic child and turns a useful guard into an obstacle. The CI half is a membership test against the build's own output, which is the same shape as enforcing a Core Web Vitals budget in CI, with one advantage: a route that left the prerendered set is a boolean, not a noisy timing number.

// scripts/check-rendering.ts — run after `next build`. Node 22.6+ executes
// TypeScript directly with `node --experimental-strip-types`.
import { readFileSync } from 'node:fs';

type PrerenderEntry = { compute?: string; initialRevalidateSeconds?: number | false };
type Manifest = { routes: Record<string, PrerenderEntry> };

// false = build-time only; a number = the revalidate budget in seconds. A route
// that switched to per-request rendering is simply absent from this file.
const CONTRACT: Readonly<Record<string, number | false>> = {
  '/en/blog': false, '/en/services': false, '/en/products': 300,
};

const manifest = JSON.parse(readFileSync('.next/prerender-manifest.json', 'utf8')) as Manifest;
const failures: string[] = [];

for (const [route, budget] of Object.entries(CONTRACT)) {
  const entry = manifest.routes[route];
  const declared = entry?.initialRevalidateSeconds;
  if (entry === undefined) failures.push(`${route}: rendered per request`);
  else if (entry.compute !== undefined && entry.compute !== 'static') failures.push(`${route}: compute=${entry.compute}`);
  else if (budget === false && declared !== false) failures.push(`${route}: revalidate=${String(declared)}`);
  else if (typeof budget === 'number' && typeof declared === 'number' && declared > budget) failures.push(`${route}: ${declared}s over the ${budget}s budget`);
}

if (failures.length > 0) {
  console.error(`rendering contract violated:\n${failures.map((line) => `  - ${line}`).join('\n')}`);
  process.exit(1);
}
console.log(`contract holds for ${Object.keys(CONTRACT).length} routes`);

The manifest is an internal Next.js artifact and its shape has changed between majors, so the assertion belongs in one file with the reason written above it. What it buys is worth that upkeep: when someone adds a header read in a shared component, the route disappears from the manifest, the assertion reports the route by path, and the build fails in the same job that produced the regression.

Reading the same fact by hand takes two commands, and the symbols are the whole diagnosis:

pnpm build | tee /tmp/next-build.log
# ○ static · ● prerendered via generateStaticParams · ƒ per-request · ◐ partial
grep -E '[○●ƒ◐] /(en|zh)/' /tmp/next-build.log
# the machine-readable version of the same route table
node -e "const m=require('./.next/prerender-manifest.json');console.log(Object.keys(m.routes).length,'prerendered routes')"

When is per-request rendering the right call?

There are cases where static is the wrong answer and forcing it costs more than it saves.

Route intentRendering modeEnforcementAccepted staleness
Marketing pages, docs, articlesprerendered at builddynamic = 'error' on the pageuntil the next deploy
Catalog, pricing, listingsprerendered with revalidationrevalidate = 300, tags for invalidationfive minutes
Authenticated dashboardsper-request, no shared cacheforce-dynamic on that segment onlynone
Payments and order stateper-request, or a client fetch with its own cacheexplicit, documented per routenone
Filtered search over user inputprerendered shell plus a client querythe page never reads searchParamsnone
Combinatorial routes in the thousandsper-requestdeliberate, not accidentalnone

The honest limits of my own advice are worth stating plainly. Below roughly a few thousand visits a month, dynamic = 'error' is bureaucracy: per-request rendering costs almost nothing at that traffic level, and the guard buys you a maintenance obligation instead of a saving. Static is also wrong for any page whose content is a function of the visitor, and the guard should not be spread across a layout to make a point. And prerendering does not make a page small: one English post in this site's build is 137,842 bytes of HTML on disk and 25,024 bytes gzipped, because the first byte carries the flight payload as well as the markup. Static rendering solves latency and compute, not bytes; a large payload is a separate budget problem with a separate fix.

There is also a build-time ceiling worth estimating before you prerender a matrix. My build produced 70 pages in 4.8 seconds across nine workers, which is about 69 milliseconds of wall clock per page. That rate is fine for hundreds of routes and meaningless for tens of thousands, because the constraint stops being the CPU and becomes the data source every worker is querying. Past that point, per-request rendering with a cache in front is cheaper than a build that takes longer than the deploy cadence.

Closing: rendering mode is a per-route argument, not a project setting

Run next build, read the route table, and treat every ƒ as a decision that owes you a sentence in the file where it lives. Add dynamic = 'error' to the routes that must never move, assert the prerender manifest in CI so the decision survives a quarter of unrelated work, and keep the exceptions visible enough that the next person can review them. The engineering is not choosing between static and dynamic once, at the start of a project; it is noticing which of the two you already chose, and whether anything has quietly changed it since.

Keep reading

More in Engineering

Ready to build a system?[ Book a Call ]