In a Next.js App Router route with Partial Prerendering enabled, what decides which parts of the component tree land in the prerendered static shell and which become dynamic holes?
answer
- the boundary is the seam
- the build stops where the request matters
- fallback is what the shell can render
- no boundary above the read, no shell
- layouts sit above every child boundary
basics
~20 sThe <Suspense> boundary is the seam. Anything the build can resolve becomes shell HTML; a subtree that reads request-time data must sit inside a boundary, so its fallback goes in the shell and its children are postponed to request time.
solid answer
~50 sAt build time Next renders the route as far as it can. When it reaches a component that depends on the incoming request, the nearest enclosing `<Suspense>` boundary is what lets it stop cleanly: Next prerenders that boundary's fallback into the shell and postpones its children until a real request arrives. Everything outside such a boundary that the build could resolve becomes real HTML in the shell. The consequence is the part candidates miss — a request-time read with **no** boundary above it has no fallback to prerender, so the build cannot produce a shell for that route at all and it falls back to being fully dynamic. That includes reads in `layout.tsx`, which sits above every child route. In practice, adopting PPR is mostly the work of pushing each request-dependent component down under a boundary with a fallback worth looking at.
code
tsx · 7 linesimport { cookies } from 'next/headers'
// No <Suspense> above this read: nothing can be prerendered for the route.
export default async function Page() {
const theme = (await cookies()).get('theme')?.value ?? 'light'
return <main data-theme={theme}>Product details</main>
}go deeper
Recall that a Suspense boundary marks the dynamic part, and that the fallback inside it is what gets built into the page ahead of time. Be able to point at the boundary in a snippet and say which side is static.
Explain that the build renders until it hits a request-dependent read and needs an enclosing boundary to stop at, and state clearly what happens when there is none — no shell, whole route dynamic. Know that layouts count.
Demonstrate that you would audit the layout chain, not just the page, and that you size boundaries to keep the shell as large as possible. Be able to explain why cached fetches stay in the shell while cookie reads do not.
Own the consequence for the codebase: this makes component structure a caching contract, enforced by nobody unless you enforce it. Have a position on how that gets reviewed and how you stop one layout edit from silently deoptimising a section.
## The rule in one sentence The `<Suspense>` boundary is the seam between the prerendered shell and the per-request holes. Whatever the build can resolve becomes static HTML; whatever it cannot must be wrapped, or there is no shell. ## Why a boundary is required, not merely advisable Partial Prerendering works by rendering the route at build time and **stopping** wherever the answer depends on the request. Stopping is only useful if there is something to put in the gap. A `<Suspense>` boundary supplies exactly that: its `fallback` is markup the build can render immediately, and its position tells Next where to resume when a real request arrives. So the boundary is doing double duty. To React it is a loading placeholder. To Next's prerender it is *the declared edge of the static output* — the place where "known at build" ends and "known only per request" begins. ## What ends up in the shell For a boundary that wraps a request-dependent component, the shell contains the **fallback**, not the component. For everything else — headings, copy, layout chrome, data the build could fetch and cache — the shell contains the fully rendered markup. The shell is by construction identical for every visitor, which is what makes it cacheable. ## Without a boundary, the route has no shell This is the failure worth internalising. Consider: ```tsx export default async function Page() { const theme = (await cookies()).get('theme')?.value ?? 'light' return <main data-theme={theme}>Product details</main> } ``` The read sits at the top of the component with nothing above it. There is no fallback to prerender and no resume point, so the build cannot emit a partial shell — the route reverts to ordinary dynamic rendering and every request pays for the whole page. Nothing errors; you simply do not get the benefit, which is why this is usually discovered by reading the build output rather than by a crash. The fix is structural: move the read into a child component and wrap that child. ```tsx export default function Page() { return ( <main> <h1>Product details</h1> <Suspense fallback={<ThemedSkeleton />}> <ThemedPanel /> </Suspense> </main> ) } ``` ## Layouts are above everything A `layout.tsx` renders around every route beneath it, so a request-dependent read there is above every boundary those child routes contain. One such read in a shared layout can quietly remove the shell from a whole section of the app. When a route unexpectedly has no shell, the layout chain is the first place to look, not the page. ## The boundary is now a caching decision too Under PPR, where you put a boundary is no longer purely a loading-UX choice — it also decides how much of the page is prebuilt. Wrap too much and you push otherwise-static content into the per-request path, shrinking the shell to almost nothing. Wrap too little and you cannot express the split at all. The useful instinct is to wrap the *smallest* subtree that genuinely depends on the request, so the maximum surface stays in the shell. ## Design the fallback, do not throw it away Because the fallback is what real users see first — served instantly from cache, before any per-request work has started — it is not a throwaway spinner. It should reserve the same space the real content will occupy, so that swapping in the resolved content does not shift the layout, and it should be recognisable as the thing that is loading. A fallback of `null` technically works and produces a shell with a visible gap that jumps when filled. ## Cached data is not dynamic A component that fetches data the build can resolve and cache is not a hole — it renders into the shell like anything else. The seam is drawn by dependence on **this request** (cookies, headers, the incoming URL's search parameters, explicitly uncached reads), not by the mere presence of a `fetch`. Candidates who assume every network call forces a hole end up wrapping far more than necessary.
- Does a component that calls fetch always become a dynamic hole?No. The seam is drawn by dependence on the incoming request, not by the presence of a network call. A fetch whose result the build can resolve and cache renders straight into the shell. Only reads tied to this request — cookies, headers, search params, explicitly uncached fetches — need a boundary. Wrapping every fetch shrinks the shell for no reason.
- A route unexpectedly has no static shell, but its page.tsx wraps every dynamic component. Where do you look?The layout chain. A `layout.tsx` renders above every route nested under it, so a request-dependent read there sits above all of the page's boundaries and removes the shell for every child route at once. Check each layout from the route back up to the root before re-reading the page itself.
- What should the fallback contain, given that real users see it on every cache hit?Something that occupies the final content's dimensions and reads as the thing that is loading — a sized skeleton, a dashed placeholder, a neutral count. It is served instantly from the shell, so it is the first thing users see, and a `null` or unsized fallback produces a visible gap that shifts the layout when the real content streams in.
saying these in an interview costs you the question
- Believes every fetch call creates a dynamic hole
- Thinks a dynamic read without a boundary just renders slower
- Forgets that layout.tsx sits above every child route's boundaries
- Treats the fallback as a throwaway spinner nobody sees
- Says the split is configured per route rather than by component structure