Static or Dynamic — Reading the Build Output
IntermediateEvery route is one or the other, the build tells you which, and a route silently flipping to dynamic is the most common performance regression in a Next codebase.
Overview
The build output is the most useful and least read artefact Next produces. It lists every route with a symbol saying how it will be served, and that symbol is the answer to "why is this page slow in production". Routes drift from static to dynamic accidentally — someone adds a cookie read to a shared layout, or reads searchParams to show a filter — and nothing fails, the page just stops being served from the CDN. Reading this output after a build, and knowing what to do when a route has moved, is a small habit with a large payoff.
The Symbols
What the build prints, and what each line means for a user.
Route (app) Size First Load JS
┌ ○ / 5.2 kB 98 kB
├ ● /learn/[topic]/[concept] 1.8 kB 95 kB
├ ├ /learn/react/react-usestate
├ └ [+199 more paths]
├ ƒ /dashboard 3.1 kB 96 kB
└ ƒ /api/health 0 B 0 B
// ○ Static prerendered at build, served from the CDN. Fastest.
// ● SSG prerendered from generateStaticParams. Also CDN.
// ƒ Dynamic rendered per request on a server. Costs latency and money.
// λ / Edge runs on the edge runtime.
// What to look for after a build:
// - a page you expected to be ○ showing as ƒ
// - First Load JS creeping up release after release
// - a route with far more paths than you expected
// Diffing this between deploys catches regressions that no test does.
// Some teams commit the summary and review changes to it.
// Force a decision when you want it enforced rather than inferred:
export const dynamic = 'force-static' // build fails on dynamic API use
export const dynamic = 'force-dynamic' // never prerenderWhat Flips a Route
The specific triggers, and how to find the one responsible.
// Anything request-dependent, anywhere in the tree:
cookies() headers() draftMode() connection()
searchParams as a page prop
fetch(..., { cache: 'no-store' })
export const dynamic = 'force-dynamic'
export const revalidate = 0
// And critically, these are INHERITED DOWNWARD. A cookies() call in
// app/layout.tsx makes every route in the application dynamic.
// Finding the culprit when a route unexpectedly shows ƒ:
// 1. Check the shared layouts first — that is usually it.
// 2. Search the route's tree for the calls above.
// 3. Build with the debug flag and read which access it reports:
// "Route /problems couldn't be rendered statically because it
// used cookies"
// The usual accidental cause: an auth helper called in a layout to
// decide whether to show a "Sign in" button. That one button makes
// the whole site render per request.
// Fix: render the auth-dependent part in its own client component,
// or isolate it behind a Suspense boundary so the shell stays static.Keeping Pages Static
The patterns for having per-user UI on an otherwise static page.
// A static page with a signed-in header:
// Option A — the header reads the session on the CLIENT
'use client'
const { user } = useAuth() // page stays static, header hydrates
// Option B — isolate the dynamic bit behind Suspense (PPR-shaped)
<Suspense fallback={<HeaderSkeleton />}>
<AuthHeader /> {/* reads cookies() — only this is dynamic */}
</Suspense>
// Option C — middleware sets a header, the page reads nothing
// (still dynamic, but cheap)
// generateStaticParams for known dynamic routes
export async function generateStaticParams() {
return (await getAllSlugs()).map((slug) => ({ slug }))
}
export const dynamicParams = true // render unknown ones on demand,
// then cache (the usual choice)
export const dynamicParams = false // 404 for anything unlisted
// For a large catalogue, prerender the popular ones and let the tail
// render on demand:
export async function generateStaticParams() {
return (await getTopSlugs(500)).map((slug) => ({ slug }))
}
// 200 concept pages at build, the rest on first visit — a shorter
// build with the same user-visible speed after the first hit.Key Points to Remember
- 1The build output labels every route static, SSG, dynamic or edge — read it after each build
- 2A route that unexpectedly became dynamic is the most common silent performance regression
- 3Dynamic APIs are inherited downward, so one cookies() call in a root layout makes the whole app dynamic
- 4Keep a page static by moving per-user UI into a client component or isolating it behind Suspense
- 5generateStaticParams with dynamicParams: true prerenders the popular paths and renders the tail on demand
Interview Questions
Sign in to ask AriaWhat do the symbols in the Next.js build output mean?
Your homepage silently became dynamic. How do you find out why?
How do you show a signed-in username on an otherwise static page?
Ask Aria about Static or Dynamic — Reading the Build Output
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.