Next.js Partial Prerendering: Static Shell, Dynamic Holes

Photo by markus spiske on flickr
Partial Prerendering, or PPR, is a rendering strategy that combines a statically prerendered shell with dynamic content streamed in at request time, all within a single route. Static parts of the page are cached and served instantly, while components wrapped in Suspense that depend on request-time data render on demand and stream into the page over the same response.
You set the ppr option to incremental in next.config, then export experimental_ppr as true from the layout or page file of each route you want to opt in. That export cascades to nested layouts and pages under that segment, so it only needs to be declared once per route.
No. Suspense only creates a boundary that can show a fallback while content loads; it does not by itself make a component dynamic. A component becomes dynamic when it reads a request-time API such as cookies, headers, or searchParams, and Suspense is what lets you contain that dynamic behavior without forcing the entire route to render dynamically.
Next.js currently documents PPR as an experimental feature and explicitly advises against relying on it in production until it stabilizes. Teams experimenting with it should treat it as something to validate carefully and re-test after every Next.js upgrade, since experimental behavior can change between releases.
PPR helps most on routes where a large, mostly static page contains a small slice of genuinely personalized content, such as a dashboard with a live order feed or a product page with a live stock count. On routes that are dynamic almost everywhere, the static shell ends up tiny and the added complexity of managing Suspense boundaries provides little benefit.

Photo by markus spiske on flickr
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.
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.
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.
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>
</>
)
}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.
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.
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. |
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.
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.
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.