Most rendering debates in Next.js come down to a binary choice: render the whole route at build time for speed, or render it per request for freshness. Partial Prerendering, or PPR, refuses that binary. It lets a single route serve a static shell instantly while the parts that genuinely need request-time data stream in afterward, in the same response.
This post walks through the mental model behind PPR, how Suspense boundaries decide what gets prerendered versus streamed, a working example you can adapt, the request lifecycle under the hood, common pitfalls teams run into, and the tradeoffs worth knowing before you flip it on for a route.
The mental model: shell first, holes second
Picture a dashboard page. The navigation, page title, and static marketing copy never change between requests. But a live order feed or a user's account balance depends on who is asking and when. Traditional static rendering forces the whole page to be either fully static or fully dynamic, which means one personalized widget can quietly drag an entire route out of the static cache and into full dynamic rendering on every request. PPR splits the difference at the component level instead of the route level.
At build time, Next.js walks the component tree for a route and prerenders everything it can. Whenever it hits a component wrapped in a Suspense boundary, it renders the fallback UI instead and leaves a hole. The result is a single HTML shell, cached and served instantly, with named gaps where dynamic content will later be injected by the framework's own streaming runtime. Because the shell is a normal cached artifact, it benefits from the same CDN and edge caching that any static page would, even though part of the eventual response depends on the specific visitor.
- Static shell — layout, headers, and any component that reads no request-time data, generated once and cached like a normal static page.
- Dynamic holes — components wrapped in Suspense that use request-scoped APIs, rendered at request time and streamed into their designated slot.
- One response — the shell and the streamed holes are delivered over a single HTTP request, so there is no extra round trip for the dynamic parts.
Wrapping a component in Suspense does not make it dynamic by itself. A component only becomes dynamic when it reads a request-time API such as cookies, headers, or searchParams. Suspense is the boundary that contains that dynamic behavior so the rest of the page can stay static.
Turning it on: config and route opt-in
PPR is opt-in at two levels. First, the next.config file sets the ppr option to the string incremental, which unlocks route-by-route adoption instead of forcing it project-wide. Second, each route that wants PPR exports a experimental_ppr constant set to true from its layout or page file. Routes without that export default to false and render as before.
The experimental_ppr flag cascades down to every nested layout and page under that route segment, so you only need to set it once at the top of the segment you are adopting. A child segment can opt back out by exporting experimental_ppr as false, which is useful when a specific nested page is fully dynamic and gains nothing from a static shell.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
},
}
export default nextConfig
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { StaticHeader } from './static-header'
import { LiveOrderFeed, FeedSkeleton } from './live-order-feed'
export const experimental_ppr = true
export default function DashboardPage() {
return (
<>
<StaticHeader />
<Suspense fallback={<FeedSkeleton />}>
<LiveOrderFeed />
</Suspense>
</>
)
}How a request actually flows
Once a route has PPR enabled and at least one Suspense boundary around dynamic content, a request to that route follows a predictable sequence. Understanding this sequence matters because it explains why PPR can feel fast even when the dynamic portion of the page is doing real work, such as a database query or an authenticated fetch.
- The server immediately returns the prerendered static shell, including the Suspense fallback UI for every dynamic hole, so the browser can paint something meaningful right away.
- In parallel, the server starts rendering each dynamic component that depends on request-time data, such as a cookie-based session or a database read scoped to the current user.
- As each dynamic piece finishes, it streams into its hole over the same connection, replacing the fallback without a page reload or a second network request.
PPR is still an experimental feature and Next.js explicitly recommends against relying on it in production until it stabilizes. Treat any route you adopt it on as something you can roll back cleanly, and re-test after every Next.js upgrade since the underlying behavior can change between releases.
A worked example
The snippet below shows the minimum needed to adopt PPR for one route: the config flag, the route-level opt-in, and a Suspense boundary separating the static header from a dynamic, per-user order feed.
| Piece | Where it lives | What it controls |
|---|---|---|
| ppr: incremental | next.config.ts | Unlocks per-route adoption instead of an all-or-nothing project setting. |
| experimental_ppr = true | layout.tsx or page.tsx | Opts this route segment and its children into partial prerendering. |
| Suspense boundary | Any component using cookies, headers, or searchParams | Marks the dynamic hole and supplies the fallback shown in the static shell. |
Where PPR actually helps, and where it does not
PPR earns its complexity on routes that mix a large, mostly static surface with a small, genuinely personalized slice. A marketing-heavy dashboard, a product page with a static description plus a live stock count, or a blog layout with a static article and a dynamic comment count are good candidates.
- Best fit: content-heavy pages where only a small widget needs live, per-request data, since the shell dominates perceived load time.
- Best fit: routes currently fully dynamic just because of one small dynamic API call, where isolating that call behind Suspense unlocks a cached shell for everything else.
- Poor fit: routes that are dynamic almost everywhere, where the static shell would be tiny and the added complexity of managing Suspense boundaries buys little.
It is also worth remembering that PPR complements, rather than replaces, other Next.js rendering tools. Static rendering with revalidation, dynamic rendering, and PPR are three points on the same spectrum, and picking the right one is a per-route decision, not a global one.
Pitfalls teams run into during adoption
The most common mistake is placing a Suspense boundary too high in the tree, around a whole page section instead of just the specific component that reads request-time data. That turns a small, targeted dynamic hole into a large one, and the fallback UI ends up covering content that could have stayed static. Push the boundary down to the smallest component that actually needs to be dynamic, and let everything around it remain part of the shell.
A second pitfall is forgetting that data fetched inside a dynamic component still needs its own caching strategy. PPR controls when a component renders relative to the request, not whether a fetch call inside it is cached. A dynamic component that calls an uncached, slow endpoint on every request will still be slow to stream in, even though the rest of the page loaded instantly. Pair PPR with sensible fetch caching or request memoization inside the dynamic slice itself.
- Keep Suspense boundaries as tight as possible around the specific component that reads cookies, headers, or searchParams.
- Give dynamic components their own loading state that matches their actual size, rather than a generic full-page skeleton.
- Re-run your build after adopting PPR and check the build output for which routes were partially versus fully prerendered, since a component higher up the tree can silently opt a whole segment into full dynamic rendering.

