Type something to search...
Next.js Building your application

Next.js Building your application

Most developers treat next build as a black box. You run it, you wait, and either it prints a green checkmark or it doesn't. If it fails, you scroll up looking for the word "Error," copy whatever surrounds it into a search engine, and try a fix that sounds plausible. If it succeeds, you deploy and move on without ever reading the route table it just printed.

That's a mistake, and not because you need to memorize compiler internals. It's a mistake because the build output is one of the few places Next.js tells you, in plain terms, exactly how each of your routes will behave in production — what's prerendered, what streams, what re-runs on every request, and how long each cached response stays fresh. Learn to read it and you catch performance regressions before a user ever sees them. Ignore it and you find out the hard way, usually from a support ticket about a slow page three weeks after you shipped it.

What next build Actually Does

Running next build isn't a single step, it's a pipeline. Understanding the phases makes the eventual output — and the errors — much easier to reason about.

Setup. Next.js loads your .env files, validates next.config, and generates a build ID that gets baked into asset filenames for cache-busting.

Route discovery. It scans app/ (and pages/, if you still have any) for every route, and picks up root-level convention files like proxy.ts and instrumentation.ts. This is also where it generates the TypeScript definitions that make routes like <Link href="/products/[id]"> type-checked against your actual folder structure.

Compilation. Turbopack (or webpack, if you've opted out) bundles your client, server, and edge code separately, transpiles TypeScript and JSX, tree-shakes anything unused, and optimizes CSS and fonts. Type checking runs in parallel with this rather than blocking it, which is part of why builds got noticeably faster over the last few major versions.

Static analysis. This is the phase most people never think about, and it's the one that actually decides how your app behaves at runtime. Next.js classifies every route as either prerenderable or not, collects the output of any generateStaticParams functions, and checks for what it calls prerender-blocking errors — code that tries to read request-specific data during a pass that has no request to read from.

Prerendering. Static pages and partial-prerender shells get rendered to HTML here. React Server Component payloads are generated for client-side navigations too, which is why navigating between two prerendered pages in a Next.js app feels closer to a single-page app than a traditional multi-page site.

Output. Everything lands in .next/. If you're using output: 'standalone', only the files your app actually needs at runtime get bundled, which matters a lot for container image size. If you're using output: 'export', you get a fully static site with no server component at all. Either way, the last thing printed is the route table.

That route table is the part worth actually reading, every time.

Reading the Route Table

Running the build is the same regardless of package manager:

pnpm build
npm run build
yarn build

After a successful build, Next.js prints a table with one row per route and a symbol showing how that route is served:

SymbolNameBehavior
StaticFully prerendered at build time. Served with no server work at request time.
Partial PrerenderA static shell is served instantly; dynamic content streams in afterward.
SSGPrerendered static HTML, typically from generateStaticParams or the older getStaticProps.
ƒDynamicRendered on the server, from scratch, for every single request.

If you've used Next.js for a while, , , and ƒ will look familiar — those three have always described how a route renders, on both the App Router and the Pages Router. What's new, if you've enabled Cache Components via cacheComponents: true in next.config.ts, is . Partial Prerendering becomes the default rendering model under that flag, and it changes the mental model in a way that's worth sitting with for a second: instead of a route being either static or dynamic, it's a spectrum. A single route can prerender its layout, its navigation, and most of its markup at build time, then stream in exactly the parts that genuinely need a live request — a personalized greeting, a shopping cart count, live inventory — without forcing the entire page to fall back to full server rendering.

Under this model, ƒ becomes the exception rather than the norm. You'll mostly see it on routes with nothing to prerender at all: Route Handlers that depend entirely on the incoming request, Proxy (the file that used to be called Middleware), and metadata that has to be computed per-request, like a dynamically generated opengraph-image.

One subtlety worth flagging: the symbol reflects what actually happened during prerendering, not which config flags a route exports. A route with export const instant = false doesn't automatically show ƒ — it shows whatever its actual prerender behavior turned out to be. Config options change what's allowed, not what gets printed.

A Worked Example: Chasing a Build Failure to Its Fix

Reading a table of symbols is one thing. Understanding why a route ended up with the symbol it did is another, and the fastest way to build that intuition is to watch a build actually fail and then fix it, step by step. This is also the single most common way people meet Cache Components for the first time — not by reading about it, but by having a production build reject something that worked fine in next dev.

Here's a small store app with a dynamic product page:

app/
├── layout.tsx
├── page.tsx
└── products/
    └── [id]/
        └── page.tsx

And the page itself, doing the most naive possible thing — reading the route param and fetching a product by ID:

// app/products/[id]/page.tsx
export default async function Page(props: PageProps<"/products/[id]">) {
  const { id } = await props.params;
  const res = await fetch(`https://api.example.com/products/${id}`);
  const product = await res.json();
  return <div>{product.name}</div>;
}

This looks completely reasonable, and it will run fine in development. Run next build against it with Cache Components enabled, though, and it fails:

Error: Route "/products/[id]": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route

This error is doing you a favor, even though it doesn't feel that way at 5pm on a Friday. It's catching, at build time, exactly the kind of thing that would otherwise slip into production and quietly make one route slower than the rest of your app, discoverable only once someone notices the Core Web Vitals dashboard trending the wrong way. The build is refusing to guess whether you meant for this route to block on a network call — it's making you decide explicitly.

Getting a Better Stack Trace

The error above tells you what went wrong, but production builds minify server code and skip source maps by default, so the actual line number can be useless. Before doing anything else, re-run with debug flags on:

next build --debug-prerender

This disables minification, turns source maps back on for server bundles, and — usefully — keeps going after the first failure instead of stopping, so you see every blocking route in one pass instead of playing whack-a-mole one build at a time. With it on, the same error now points at an exact line:

Error: Route "/products/[id]": Next.js encountered uncached or runtime data during prerendering.
  ...
    at Page (app/products/[id]/page.tsx:2:30)
  1 | export default async function Page(props: PageProps<'/products/[id]'>) {
> 2 |   const { id } = await props.params
    |                              ^

Do not ship a build produced with this flag. It exists purely for diagnosis — it turns off optimizations your production traffic actually needs. If you want the fastest possible loop while you're iterating on just one route in a large app, pair it with --debug-build-paths so the build only touches the files you're actively fixing:

next build --debug-prerender --debug-build-paths="app/products/[id]/page.tsx"

That flag accepts globs and comma-separated paths, and a ! prefix to exclude specific ones, which is genuinely useful once your app has more than a handful of routes and a full build takes longer than your attention span.

Fix Option 1: Stream It

The simplest fix, and often the right default, is to accept that this data can't be known at build time and let it stream in instead. Add a loading.tsx file next to the page:

// app/products/[id]/loading.tsx
export default function Loading() {
  return <div>Loading...</div>;
}

This file does something easy to miss on first read: it implicitly wraps the whole route segment in a <Suspense> boundary. Next.js prerenders the Loading fallback as the route's static shell, and everything that touches params or performs an uncached fetch runs later, at request time, streaming into that shell.

Run the build again and the route passes, now showing the partial-prerender symbol:

Route (app)
┌ ○ /
├ ○ /_not-found
└   /products/[id]
  └ ◐ /products/[id]    # Shell prerendered, content streams on request

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

Visitors now get something on screen instantly — the shell — while the real product data streams in behind it. This is usually a strict improvement over the old all-or-nothing SSR model, where the entire response waited on the slowest data dependency.

Fix Option 2: Tell Next.js Which Params Exist

Right now every product page collapses into a single fallback row, because Next.js has no idea which product IDs actually exist. Exporting generateStaticParams fixes that:

// app/products/[id]/page.tsx
export async function generateStaticParams() {
  const res = await fetch("https://api.example.com/products");
  const products = await res.json();
  return products.map((product) => ({ id: product.id }));
}

export default async function Page(props: PageProps<"/products/[id]">) {
  const { id } = await props.params;
  const res = await fetch(`https://api.example.com/products/${id}`);
  const product = await res.json();
  return <div>{product.name}</div>;
}

The build now lists a row per known product, in addition to the fallback row for anything unlisted:

Route (app)
┌ ○ /
├ ○ /_not-found
└   /products/[id]
  ├ ◐ /products/[id]
  ├ ◐ /products/1       # Params known, data still uncached
  ├ ◐ /products/2       # Params known, data still uncached
  └ ◐ /products/3       # Params known, data still uncached

Notice these still show , not . Listing the params only tells Next.js which pages exist — it says nothing about whether the data behind them is safe to run at build time. The fetch inside the page is still an uncached network call, so it still has to happen at request time. This distinction trips people up constantly: "I added generateStaticParams, why is my page still not fully static?" Because knowing the URLs and knowing it's safe to run the data fetch during the build are two separate questions, and generateStaticParams only answers the first one.

One easy-to-miss requirement here: generateStaticParams must return at least one entry, or the build fails outright. An intentionally empty result isn't a valid way to say "no static params," oddly enough — you'd omit the export entirely for that.

Fix Option 3: Actually Cache the Data

To get all the way to , wrap the data access itself in use cache:

// app/products/[id]/page.tsx
export async function generateStaticParams() {
  const res = await fetch("https://api.example.com/products");
  const products = await res.json();
  return products.map((product) => ({ id: product.id }));
}

async function getProduct(id: string) {
  "use cache";
  const res = await fetch(`https://api.example.com/products/${id}`);
  return res.json();
}

export default async function Page(props: PageProps<"/products/[id]">) {
  const { id } = await props.params;
  const product = await getProduct(id);
  return <div>{product.name}</div>;
}

Now the build produces this:

Route (app)           Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ○ /products                15m      1y
└   /products/[id]
  ├ ◐ /products/[id]    # Unlisted params stream on demand
  ├ ○ /products/1            15m      1y
  ├ ○ /products/2            15m      1y
  └ ○ /products/3            15m      1y

The listed products are now fully prerendered, because their params are known and their data is safe to run during the build. Only the unlisted-param fallback stays partial. Two columns worth paying attention to here that people skip past: Revalidate and Expire. Even if you never called cacheLife explicitly, every cached function falls back to a default profile — 15 minutes to revalidate, effectively never expiring (capped at a year in the display). If a route contains multiple cached calls with different lifetimes, the table reports the shortest one across the whole route, which is a detail worth remembering the first time the numbers in the table don't match the cacheLife call you were staring at in the file — check whether something else on that route has a shorter profile.

When a visitor requests a product that wasn't in the listed set at build time, they still get an instant response: the static shell renders immediately, the real content streams in, and the page gets upgraded in the background for the next visitor. That's Incremental Static Regeneration under the Cache Components model, and it means "unlisted" doesn't mean "slow forever" — it means "slow once."

Fix Option 4: Deliberately Allow a Blocking Route

Sometimes the honest answer is that a route genuinely can't be prerendered in any useful way, and you'd rather it just block than show a placeholder. Setting instant = false tells the build that's intentional, rather than an oversight:

// app/products/[id]/page.tsx
export const instant = false;

export default async function Page(props: PageProps<"/products/[id]">) {
  const { id } = await props.params;
  const res = await fetch(`https://api.example.com/products/${id}`);
  const product = await res.json();
  return <div>{product.name}</div>;
}

The build passes, and the route table looks identical to the streaming version — same symbol — but the actual runtime behavior is different. There's no fallback UI here. A visitor sees nothing until the fetch resolves. Use this sparingly and deliberately; it's an opt-out of a safety check, not a performance optimization. If you reach for it reflexively every time a build complains, you've quietly turned off the exact protection that catches slow, uncached routes before they reach production.

What the Docs Don't Tell You

A few things worth knowing that the official guide doesn't spend much time on:

next dev will not catch any of this for you. Development mode is intentionally forgiving about runtime data access, because forcing you to think about prerendering on every keystroke would make local development miserable. That means the first time most teams meet a prerender-blocking error is in CI, or worse, on a deploy that was supposed to be routine. If your team ships to a preview environment before production, that's the cheapest place to catch these — treat a failing preview build as equivalent to a failing test suite, not as an annoyance to route around.

The route table is a better performance dashboard than most people realize. It's easy to add a data fetch to a page during a feature review, ship it, and never notice that the route quietly flipped from to or, worse, effectively became blocking. Diffing the route table between builds — some teams script this into CI as a warning, not a hard failure — catches regressions that a visual QA pass never will.

--debug-build-paths is worth using long before your app is "large enough" to need it. Waiting for a full production build to iterate on a single prerender error is a bad use of anyone's afternoon. Get in the habit of scoping builds to the route you're actually touching.

The Revalidate/Expire columns are a good place to sanity-check assumptions about staleness. If you expect a page to update within a minute of a content change and the table says 15 minutes, that's your cacheLife default talking, not a bug.

Build output format is meant to be read by humans, not parsed by scripts. If you need machine-readable output for CI tooling, don't scrape the symbols table — that's a good candidate to raise with your platform/adapter's build integration instead of building a fragile parser around printed Unicode characters that could change between versions.

Key Takeaways

SituationWhat It MeansWhat To Do
Route shows Fully prerendered, no server work per requestNothing — this is the best case
Route shows Static shell ships instantly, rest streams inConfirm the streamed part is genuinely dynamic, not just uncached
Route shows ƒNo prerendering happened at allExpected for request-dependent Route Handlers and Proxy; investigate otherwise
Build fails with a prerender-blocking errorRuntime or uncached data read outside <Suspense>Wrap it in <Suspense>, cache it with use cache, or explicitly allow it with instant = false
Error location is unclearProduction builds minify and skip source mapsRe-run with --debug-prerender, never ship that build
Iterating on one route in a big appFull builds are slowScope the build with --debug-build-paths

Reading the build output isn't a one-time skill you pick up and forget — it's the fastest feedback loop Next.js gives you about how your app will actually behave once real traffic hits it. Treat a passing build as informative, not just as a gate to get past, and you'll catch far more regressions before a user ever does.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper