skip to content

A team turns on Partial Prerendering for a Next.js product route, but the build output still reports the route as fully dynamic and production TTFB is unchanged. How would you diagnose it?

level: seniorimportance: nice to knowfreq 20%

answer

  1. separate no-shell from shell-not-delivered
  2. the build output already tells you which
  3. incremental mode needs a per-route opt-in
  4. look above the page, in the layouts
  5. something downstream may be buffering the stream

basics

~20 s

Check four things in order: whether the route is actually opted in, whether a request-time read sits above every Suspense boundary (often in a layout), whether a segment config forces dynamic rendering, and whether a proxy is buffering the stream and erasing the benefit.

solid answer

~50 s

I'd separate "no shell was built" from "a shell was built but nobody feels it", because the build output already tells you which. If the route is not marked as partially prerendered, the shell was never produced: the usual causes are that `experimental.ppr` is set to `'incremental'` without the route exporting `experimental_ppr = true`, that a request-time read sits above every boundary — most often in a shared `layout.tsx` rather than the page — or that a segment exports `dynamic = 'force-dynamic'`, which overrides everything. If the build *does* mark it partially prerendered and TTFB still hasn't moved, the problem is delivery: something between Next and the user is buffering the response, so the shell isn't released until the last hole resolves. Compression middleware and reverse proxies are the usual suspects. I'd confirm with a streaming-aware request and watch when the first bytes arrive.

code

javascript · 8 lines
javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: 'incremental',
  },
}

module.exports = nextConfig

go deeper

for a junior

Know that turning PPR on is not enough on its own — the route has to actually be opted in, and the build output reports whether it worked. Start there rather than guessing.

for a middle

Be able to explain the two distinct failures behind the same symptom, and name the concrete causes of a missing shell: a missing per-route opt-in, a read above every boundary, or a forced-dynamic segment config.

for a senior

Show a diagnosis order that costs least first — read the build output, then the layout chain, then the delivery path. Be the person who thinks to check whether a proxy is buffering the stream, since that failure survives a perfectly correct build.

for a principal

Own the systemic version: this is a benefit that can be silently deleted by an unrelated infrastructure change or one layout edit. Decide what gets asserted in CI or monitored in the field so the regression is caught rather than rediscovered.

## Split the problem first There are two very different failures wearing the same symptom, and the build output distinguishes them for free. Either **no shell was produced**, in which case the route is rendered per request exactly as before, or **a shell was produced but the user never gets it early**, in which case the win is being destroyed downstream. Next's build output lists each route with a rendering marker and prints a legend explaining them; a partially prerendered route is marked distinctly from a plain dynamic one. Read that before touching code. ## Cause 1: the route is not opted in Partial Prerendering has been opt-in and experimental through Next 14 and 15, configured under the `experimental.ppr` key in `next.config`. That key takes more than a boolean: setting it to `'incremental'` enables PPR only for routes that individually opt in, by exporting `experimental_ppr = true` from the segment. ```js // next.config.js module.exports = { experimental: { ppr: 'incremental' } } ``` ```tsx // app/product/[id]/page.tsx export const experimental_ppr = true ``` The common version of this bug is a team that set the config to `'incremental'` because a guide said so, then never added the per-route export and concluded the feature does not work. Confirm which mode you are in and whether the route in question declared itself. ## Cause 2: a request-time read above every boundary A shell can only be produced if the build can stop cleanly at a `<Suspense>` boundary. A read of request data with nothing above it leaves no fallback to prerender and no resume point, so the whole route falls back to dynamic rendering. When the page itself looks correctly wrapped, the read is usually **not in the page**. A `layout.tsx` renders above every route nested beneath it, so a cookie or header read there sits above all of a page's boundaries. One such read in a shared layout deoptimises every route under it at once — and it is easy to introduce, because reading a session in the layout to decide what the nav shows is a natural thing to write. Walk the layout chain from the route up to the root. ## Cause 3: a segment config forces dynamic If any segment in the route's chain exports `dynamic = 'force-dynamic'`, the route is dynamic by declaration and no amount of boundary work changes that. It is worth grepping the route's segments for it, because it is often left behind from an unrelated debugging session months earlier. ## Cause 4: the response is being buffered This is the one that survives a correct build and is missed most often. PPR's benefit is that the shell leaves the server before the per-request work finishes. Anything in the path that waits for a complete response before forwarding it collapses that back into the original behaviour: the user sees nothing until the last hole resolves, so TTFB looks exactly as it did before. Usual culprits are a reverse proxy or ingress configured to buffer, compression middleware that accumulates the whole body before encoding it, and hosting layers that treat the handler's return value as one atomic payload rather than a stream. Test it directly — issue a request with a client that reports when the *first* bytes arrive rather than when the request completes, and compare against hitting the app process directly with the proxy bypassed. If bypassing the proxy fixes the timing, you have your answer. ## Confirming the fix After each change, re-read the build output first: it is the cheap, deterministic check for causes 1 through 3. Only once the route is marked partially prerendered does field timing become meaningful, and then the metric to watch is time to first byte and the paint that follows it — not total load time, which barely moves because the same total work is being done either way. PPR does not make the personalized query faster; it stops that query from delaying everything around it. ## Two things that are not the cause The holes being slow is not a PPR failure — they are supposed to arrive later. And a page whose above-the-fold content is almost entirely personalized will show a genuinely correct partial prerender with almost no visible benefit, because there was hardly any shell to serve. That is a route-selection problem, not a bug.

  • The build now marks the route as partially prerendered, but TTFB in production is still flat. What next?
    Delivery, not rendering. Measure when the first byte arrives rather than when the response completes, then bypass each layer in front of the app — reverse proxy, ingress, compression middleware — and re-measure. Any component that waits for a complete body before forwarding it collapses the stream back into a single blocking response and erases the entire benefit.
  • Why is grepping the route's segments for dynamic = 'force-dynamic' worth doing early?
    It is a declaration that overrides everything downstream: with it present, no amount of boundary restructuring will produce a shell. It is also cheap to check and frequently left behind from unrelated debugging weeks earlier, so it is a high-value, low-cost thing to eliminate before you start moving components around.
  • The route prerenders correctly but users report no improvement. Is that a bug?
    Not necessarily. If nearly all the above-the-fold content is personalized, the shell is almost empty and there is little to serve early — the mechanism worked and the route was a poor candidate. That is a selection decision to revisit, not a defect to debug, and it is worth checking before spending days on the pipeline.

saying these in an interview costs you the question

  • Assumes setting the config flag opts every route in automatically
  • Only inspects page.tsx and never walks the layout chain
  • Ignores proxy and compression buffering as a cause of flat TTFB
  • Measures total load time instead of time to first byte
  • Treats slow-arriving holes as evidence PPR is broken

context