A Next.js App Router page mixes static marketing copy with a personalized, per-user cart badge. What does Partial Prerendering (PPR) produce for that route, and how does it differ from rendering the whole route dynamically?
answer
- one route, two rendering times
- static part built once, ahead of time
- personalized regions left as holes
- fallback markup ships inside the build output
- holes stream into the same response
basics
~20 sPartial Prerendering produces one static HTML shell for the route at build time, with holes where the personalized parts go, then streams those holes into the same response at request time — rather than rendering the whole route per request.
solid answer
~50 sWith PPR the build renders everything it can resolve ahead of time — the marketing copy, the layout, the surrounding chrome — into a static HTML shell. Where the cart badge sits, the build cannot know the answer, so it prerenders the `<Suspense>` fallback into the shell and marks that subtree as postponed. On a request, the server can send that shell immediately (it is the same bytes for every visitor, so it can sit in a CDN), keeps the same HTTP response open, resumes rendering only the postponed subtree, and streams its real HTML in when it resolves. Rendered fully dynamically instead, nothing about the route is cacheable and time-to-first-byte waits on the personalized read. PPR is opt-in and has shipped as an experimental flag through Next 14 and 15, so check your installed version.
code
tsx · 19 linesimport { Suspense } from 'react'
import { cookies } from 'next/headers'
async function CartBadge() {
const cart = (await cookies()).get('cart')
return <span>{cart?.value ?? '0'} items</span>
}
export default function Page() {
return (
<main>
<h1>Wireless Headphones</h1>
<p>Noise cancelling, 30 hour battery.</p>
<Suspense fallback={<span>— items</span>}>
<CartBadge />
</Suspense>
</main>
)
}go deeper
Be able to say plainly that the route ships as a prebuilt static shell with placeholders, and the personalized bits arrive afterwards in the same response. Naming the Suspense fallback as what fills the placeholder is enough at this level.
Explain what the build actually emits versus what the server does per request, and be precise that it is one HTTP response rather than a client-side fetch. Expect to be pushed on which half is cacheable and why.
Show you can judge whether a given route benefits at all — how much of the page is genuinely shared, whether the shell is above the fold, and whether your host serves the prebuilt shell from a cache instead of regenerating it.
Own the framing: PPR trades a rendering-mode decision for a component-structure decision. Be ready to argue what that costs a team in refactoring and review discipline, and when the simpler all-static or all-dynamic answer is still correct.
## The choice PPR is trying to remove In the App Router, the unit of the rendering decision is the **route**. A route is either prerendered at build time — static HTML that any CDN can serve — or rendered on the server per request, because something in it depends on this request. One personalized element is enough to flip the whole route: a cart badge, a "Hi, Dana", an experiment slot. The static header, the product description, the footer — none of which have changed since the build — get re-rendered on every single request and cannot be cached at the edge. Partial Prerendering moves the unit of the decision from the route down to the **subtree**. The premise is that most pages are mostly static, with a few small personalized regions, and forcing the whole page into the dynamic bucket to serve those regions is a bad trade. ## What the build produces When a route is partially prerendered, the build renders it as far as it can. Everything resolvable ahead of time becomes real HTML. Where it hits a subtree that depends on the incoming request, it cannot produce an answer, so it does two things: it writes that subtree's `<Suspense>` fallback markup into the HTML instead, and it records the state needed to **resume** rendering from exactly that point later. The result is a single artifact — often called the *shell* — that is byte-identical for every visitor. That property is what makes it cacheable: on a CDN, in a shared cache, wherever your platform puts static output. ## What a request does A request for the route gets the shell right away, without waiting on any per-user work. The server then keeps the **same** HTTP response open, resumes rendering the postponed subtrees, and streams their HTML into that response as each one resolves. The browser swaps each fallback for the real content as it arrives. Mechanically this is the same streaming path a `<Suspense>` boundary already uses; what PPR adds is that the part *before* the holes did not have to be rendered on this request at all. ## One response, not a second fetch The most common misreading is that the holes are filled by client-side JavaScript after hydration, or by a second HTTP request. They are not. There is one URL, one navigation, one response. The personalized HTML arrives inside the initial document stream, which means it is present in the HTML that a crawler or a browser with JavaScript disabled receives once the stream completes — it is not a client-side fetch dressed up differently. ## What the code looks like The seam is an ordinary Suspense boundary: ```tsx export default function Page() { return ( <main> <h1>Wireless Headphones</h1> <p>Static description, rendered at build time.</p> <Suspense fallback={<span>— items</span>}> <CartBadge /> </Suspense> </main> ) } ``` The `<h1>` and `<p>` land in the shell as real markup. `<span>— items</span>` lands in the shell as the placeholder. `CartBadge`, which reads a cookie, runs per request. ## What is and is not cacheable The shell is cacheable because it contains no per-user data by construction. The holes are not cacheable, and that is the entire point — you are not trying to cache the personalized bytes, you are trying to stop them from dragging everything around them into the per-request path. If a route's above-the-fold content is *entirely* personalized, there is very little shell left and PPR buys you little. ## Status PPR is not on by default. Through Next 14 and 15 it lives behind the `experimental.ppr` key in `next.config`, and has required a canary release for much of that time. Because it is experimental, the exact flag name and its scope can change between versions — confirm against the Next.js version your project actually installs rather than trusting a blog post. ## Misconceptions worth naming PPR does not make personalized data cacheable. It does not run two requests. It does not require client JavaScript to fill the holes. And it is not a per-route *mode* you pick alongside static and dynamic in the way those two are mutually exclusive — it is a route that is both at once, at different depths of the tree.
- If the cart badge resolves in two milliseconds, does the user ever actually see the fallback?Usually not as a visible flash. The shell and the badge's HTML can land in the same network flush, so the browser may paint them together. The fallback is still genuinely present in the byte stream and in the build output — it is what guarantees the shell can be sent before the badge is known, regardless of how fast the badge turns out to be.
- What happens to this page if Partial Prerendering is switched off?It keeps working, just without the split. The cookie read makes the route dynamic, so the whole page is rendered per request. The `<Suspense>` boundary still does its normal job — the surrounding markup streams first and the badge streams in after — but that surrounding markup is now rendered on every request instead of being served from the build output.
- Is the personalized part visible to a crawler that does not run JavaScript?Yes, once the stream finishes. The badge's HTML is written into the same document response, not fetched by client script, so a client that reads the response to completion sees it. That is the practical difference from moving the personalized region into a client-side fetch after hydration, which leaves it absent from the initial HTML entirely.
saying these in an interview costs you the question
- Says the holes are filled by a second HTTP request
- Claims client JavaScript fetches the personalized parts after hydration
- Assumes PPR is enabled by default on every App Router route
- Thinks PPR makes per-user data cacheable at the CDN
- Describes it as a third mode you pick instead of static or dynamic