TL;DR: Next.js 16 prints four symbols in its build output — ○ Static, ● SSG, ƒ Dynamic, and ◐ Partial Prerender. If you are looking for λ, it no longer exists; it was replaced by ƒ in Next.js 14.1.
You run next build, and the route table comes back covered in symbols nobody explained:
Route (app) Size First Load JS
┌ ○ / 5.02 kB 112 kB
├ ○ /_not-found 977 B 103 kB
├ ƒ /en/[slug] 8.14 kB 128 kB
├ ● /blog 3.71 kB 109 kB
└ ◐ /dashboard 12.4 kB 141 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
The legend is printed underneath, but it only lists the symbols your build actually used — so if you have no partially prerendered routes, you never see ◐ explained, and the first time one appears it looks like an error. Worse, most articles about this still document λ, which current Next.js does not emit at all.
Below is what each one means, and — more usefully — the exact decision tree Next.js walks to choose one, taken from the source in next/dist/build/utils.js.
The four symbols
| Symbol | Label | What Next.js says | What it means for you |
|---|---|---|---|
○ |
Static | prerendered as static content | Built once at build time. Served from disk or CDN. Fastest possible. |
● |
SSG | prerendered as static HTML (uses getStaticProps / generateStaticParams) |
Also built ahead of time, but from a data-fetching function you wrote. |
ƒ |
Dynamic | server-rendered on demand | Runs on every request. Nothing is cached ahead of time. |
◐ |
Partial Prerender | prerendered as static HTML with dynamic server-streamed content | A static shell ships instantly, dynamic holes stream in after. |
Why ○ and ● are both "static"
This is the distinction that confuses people, because both are prerendered at build time and both serve instantly.
The difference is where the content came from. ○ is a route with no data-fetching function at all — an about page, a privacy policy, anything Next.js can render purely from your components. ● is a route that ran a data function at build time, so Next.js generated HTML from an external source: your CMS, your database, a filesystem read.
Practically: if ● appears on a route, that route's content is frozen at build time, and changing the underlying data does nothing until you rebuild or revalidate.
The decision tree Next.js actually uses
This is the part no explainer covers. Symbol selection is not a lookup — it is a chain of conditions evaluated in a specific order, and the order is why routes sometimes get a symbol you did not expect. Reduced from the Next.js 16 source:
if (pageInfo?.runtime) {
symbol = 'ƒ'; // an explicit runtime always wins
} else if (pageInfo?.isRoutePPREnabled) {
if (isDynamicAppRoute && !pageInfo.hasPostponed) {
symbol = 'ƒ'; // PPR on, but nothing was deferred
} else if (!pageInfo?.hasPostponed) {
symbol = '○'; // PPR on, fully static after all
} else {
symbol = '◐'; // PPR on, and something was postponed
}
} else if (pageInfo?.isStatic) {
symbol = '○';
} else if (pageInfo?.isSSG) {
symbol = '●';
} else {
symbol = 'ƒ'; // the fallback
}
Three things fall out of this that are worth knowing.
1. ƒ is the fallback, not a diagnosis
Look at the final else. If Next.js cannot prove a route is static, it gets ƒ. That means ƒ does not tell you why a route is dynamic — only that nothing qualified it as static. A single cookies(), headers(), or searchParams access anywhere in the tree is enough, and the build output will not name the culprit.
2. Setting a runtime short-circuits everything
The very first condition is pageInfo?.runtime. If you have declared a runtime on a route:
export const runtime = 'edge';
…it gets ƒ immediately, before any static analysis runs. A route that would otherwise have been fully static becomes dynamic purely because you named a runtime. If you see an unexpected ƒ on a page with no dynamic APIs, check for a stray runtime export first.
3. PPR routes can show any of three symbols
With Partial Prerendering enabled, a route only earns ◐ if something was actually postponed — that is, if Next.js found dynamic content to defer. Enable PPR on a route with nothing dynamic in it and you get plain ○, because there was no hole to stream. Enable it on a fully dynamic route and you get ƒ. Seeing ○ on a route you configured for PPR is not a misconfiguration; it means PPR had nothing to do.
What happened to λ?
Older Next.js used λ (Server) for server-rendered routes. It was replaced by ƒ in 14.1 — the lambda reference made less sense once routes ran in more places than serverless functions.
I checked the installed Next.js 16.2.4 package directly: λ appears zero times in the build output code. If a tutorial shows it, that tutorial predates 14.1. This matters when debugging, because searching for "next.js lambda symbol build" surfaces guidance for a symbol your build can no longer produce.
| Version | Server-rendered symbol |
|---|---|
| ≤ 14.0 |
λ (Server) |
| 14.1+ |
ƒ (Dynamic) |
The Revalidate and Expire columns that come and go
You may have noticed the build table sometimes has extra columns and sometimes does not. That is deliberate, not a rendering glitch. Next.js builds the header like this:
[
listType === 'app' ? 'Route (app)' : 'Route (pages)',
showRevalidate ? 'Revalidate' : '',
showExpire ? 'Expire' : '',
].filter(Boolean)
showRevalidate and showExpire are set by scanning every page for a cache-control value. If not one route in your build sets a revalidation window, the column is dropped entirely. So adding revalidation to a single route makes a new column appear across the whole table — nothing about your other routes changed.
Also worth knowing if you are following an older tutorial: Next.js used to print an ISR legend entry. In 16 that string does not exist in the build output at all — incremental regeneration is now communicated through these two columns instead of a symbol.
The revalidate: 0 trap
This one is genuinely easy to trip over. If a page uses getStaticProps with revalidate: 0, Next.js does not treat it as static-with-instant-revalidation. It reclassifies it as dynamic outright:
if (hasGSPAndRevalidateZero.has(item)) {
usedSymbols.add('ƒ');
// ...route is rendered with ƒ in the table
}
So a page you wrote as SSG, which you would expect to show ●, shows ƒ instead — and every request pays for a server render. If you wanted "always fresh but still cached briefly", use revalidate: 1, not 0. Zero means "never cache", and Next.js is telling you so through the symbol.
Reading your own build output
A healthy content site is mostly ○ and ●, with ƒ reserved for routes that genuinely need per-request data — dashboards, anything behind auth, anything reading cookies.
If a route you expected to be static shows ƒ, work through it in this order:
-
Check for a
runtimeexport. It short-circuits the whole chain, as above. -
Look for dynamic APIs —
cookies(),headers(),searchParams,connection(). One call anywhere in the rendered tree is enough, including inside a component three levels down. -
Check your fetch calls. A fetch with
cache: 'no-store'opts the route out of static rendering. - Check for a parent layout doing any of the above. This is the one that catches people — a layout reading cookies makes every route beneath it dynamic, and the build output blames the child.
To see exactly which API forced it, build with more detail:
next build --debug
Quick reference
| You see | Read it as | Act if… |
|---|---|---|
○ |
Free. Served from CDN. | Never — this is the goal. |
● |
Free, but frozen at build time. | Content looks stale — add revalidation. |
ƒ |
Costs a server render per request. | You expected static. Work the list above. |
◐ |
Static shell + streamed dynamic holes. | Never — this is usually what you want on a mixed page. |
The symbols are not decoration. On a content site, the difference between ○ and ƒ across a few hundred routes is the difference between a CDN bill and a server bill.
Originally published at dineshstack.com — read the full version with code samples and updates there.
Top comments (0)