Home/Learn/Next.js/Static or Dynamic — Reading the Build Output

Static or Dynamic — Reading the Build Output

Intermediate
Data & Caching

Every 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.

Read this after every build
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 prerender

What Flips a Route

The specific triggers, and how to find the one responsible.

Inherited downward — check layouts first
// 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.

Isolate the dynamic part; prerender the popular tail
// 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 Aria
1

What do the symbols in the Next.js build output mean?

Medium
2

Your homepage silently became dynamic. How do you find out why?

Hard
3

How do you show a signed-in username on an otherwise static page?

Hard

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.

Loading discussion…