For years the rendering decision in Next.js was binary: either pre-render the page at build time (SSG) or render it fresh on every request (SSR). T...
For further actions, you may consider blocking this person and/or reporting abuse
PPR is useful because most real pages are mixed: stable shell, semi-live data, and personalized pieces. Treating the whole page as either static or dynamic was always too blunt.
Exactly — and I think the "too blunt" problem was hiding in plain sight for a while.
The workaround most teams settled on was client-side fetching for the dynamic parts. It works, but it means users get a static shell with empty placeholders that only fill in after hydration. PPR reaches the same end result, except those dynamic sections are rendered and streamed from the server, avoiding the client-side waterfall entirely.
The trickiest part in practice is figuring out where the static shell ends.
searchParamsis probably the most common trap: a singlesearchParamsread anywhere in the tree can cause the entire page to fall out of the static shell.The fix is usually straightforward once you know what's happening—move that logic into its own Suspense-wrapped component. But it's not obvious the first time you run into it, especially because the page still works; it just silently loses the rendering behavior you expected.
That boundary is the hard design work. PPR is strongest when the team can name which parts are stable product surface and which parts are live experience. Without that split, it is easy to recreate the old client-side waterfall in a fancier shape.
That framing is precise — "stable product surface vs live experience" is actually a better question to
ask than "static vs dynamic," because it maps to how product people think rather than just how engineers
think. A PM can usually answer it faster than a developer looking at component trees.
The waterfall risk you're describing happens most often with nested Suspense where the inner component has a data dependency on the outer one. The outer boundary resolves, the inner component mounts and fires its own fetch, and you've recreated a sequential round trip — just on the server side instead of the client. PPR doesn't fix that automatically; you still have to think about whether the data dependencies
are actually sequential or just accidentally coupled.
A heuristic I've started using: if you can answer "what does this page show to a visitor with no cookies and no session?" you've identified your static shell. Everything that would change with an actual user in the picture is a dynamic hole. It's a quick gut-check that separates the architectural question from the implementation details.
That heuristic is strong. The no-cookies/no-session question makes the boundary product-readable instead of framework-readable. I would add one more check: if the dynamic hole fails or gets slow, does the static shell still communicate the page's purpose? PPR is much easier to reason about when degradation is part of the design, not a fallback accident.
That resilience check reframes Suspense fallbacks in a really useful way. Most teams treat them as loading states, but they're actually part of the product's failure and degradation strategy.
An empty div, spinner, or generic skeleton isn't meaningful degradation. If the dynamic section is slow, errors out, or never arrives, the user is left staring at a placeholder that provides no context. The page technically loads, but it doesn't communicate anything useful.
A well-designed fallback preserves the page's intent. The user should still understand where they are, what the page is about, and what action they're expected to take, even if the dynamic content never renders.
A simple test is to hide all dynamic content and show only the static shell plus Suspense fallbacks. If a designer can immediately explain the purpose of the page and the user's next step, the architecture is resilient. If it looks like a collection of loading indicators waiting for "the real page" to arrive, then too much of the page's structure depends on dynamic rendering.
This also changes how you think about PPR boundaries. The goal isn't just to maximize the static shell. It's to ensure the shell contains the information necessary for the experience to remain coherent when the dynamic pieces are delayed.
There's a parallel here with API design. Good APIs fail gracefully and return meaningful errors. Good Suspense boundaries degrade gracefully and return meaningful UI. In both cases, the fallback path deserves almost as much design attention as the happy path.
That's why treating fallback components as throwaway skeletons is often a mistake. They're not implementation details; they're part of the user experience contract. If a dynamic section disappears, the fallback becomes the product.
The searchParams isolation tip is the one I'd flag for anyone trying PPR. It's easy to design a perfect static shell and then read searchParams at the top of the page, which quietly drags the whole route dynamic and undoes the win. Pushing that read down into the one component that needs it keeps the shell cacheable, and it's the kind of thing you'd only learn from a confusing build output. The cookies() and headers() gotcha sits in the same bucket, where one innocent call decides the rendering mode for everything above it.
The "infection" mental model is exactly right, and it's worth making explicit for anyone learning PPR.
cookies(), headers(), and searchParams don't just make the component they're used in dynamic. They make everything from that point up to the nearest Suspense boundary dynamic. It's not a component-level decision—it's a boundary-level decision.
The build output is usually the easiest place to spot when this has happened, although it's not always obvious what you're looking for. If a route you expected to be partially prerendered ends up being fully dynamic, that's usually a sign that some request-time data is being read above the Suspense boundary.
The solution is almost always the same: find the call, move it lower in the tree, and wrap it in its own Suspense boundary. Once the dynamic read is isolated, the rest of the page can remain part of the static shell.
One other detail that's easy to miss: params from dynamic route segments (for example, [id]) behaves differently from searchParams. Using params does not automatically force dynamic rendering. A lot of developers assume it does and end up avoiding route parameters unnecessarily, when in reality they're perfectly compatible with a static shell.
Once you start thinking in terms of boundaries rather than components, most of the "why did this route become dynamic?" mysteries become much easier to reason about.