Partial Prerendering in Next.js: I migrated a hybrid route and show where the Suspense boundaries go
A practical step-by-step migration to PPR in Next.js 16, with the real stumbles of the dev overlay and without benchmark numbers the documentation can't back up.

I took a route with a static top section and a dynamic block dependent on a cookie, enabled Cache Components in Next.js 16, and reorganized the boundaries so the shell would come out of the CDN without dragging the whole route into dynamic rendering.
I took a route that almost everyone has in production: a static top (hero + nav) and a dynamic block that depends on the user (the dashboard/cart that reads cookies). In the old model, the simple act of reading cookies() pushed the entire route into dynamic rendering, the static top went along with it, and TTFB suffered. The point of Partial Prerendering (PPR) is exactly to break this apart: the static shell comes out of the CDN instantly, and only the dynamic piece streams at request time.
I'll show exactly what I did, including what broke.
Step 1: enable Cache Components
In Next.js 16, PPR is the default behavior once you enable Cache Components. In next.config.ts:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfigBefore touching anything, a note on method: I measure with Lighthouse (mobile mode, default throttling) and the DevTools network panel, always with the baseline recorded before the migration. In this article I won't pin down numbers from my specific route, because the values depend too much on your backend, your provider, and your CDN region. What matters here is the mechanism, and it's the same for everyone: whatever becomes the static shell no longer waits for the cookie read on the server.
Step 2: the first stumble (the build stopped)
As soon as I enabled cacheComponents, the dev overlay showed the blocking-route insight. Reason: my component that read cookies() was sitting loose in the tree, without a Suspense boundary. Next.js now requires you to explicitly handle anything that doesn't complete during prerendering. It's annoying at first, but it's exactly what keeps the whole route from turning dynamic without you noticing.
The overlay points straight to the fix: wrap the access in a .
Step 3: separate what's shell from what streams
I restructured the page into three layers: pure static, cached, and streaming. Here's how it turned out:
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'
import Link from 'next/link'
export default function Page() {
return (
<>
{/* static: enters the shell automatically */}
<header>
<h1>My Store</h1>
<nav>
<Link href="/">Home</Link> | <Link href="/ofertas">Deals</Link>
</nav>
</header>
{/* cached: also goes into the static shell */}
<Ofertas />
{/* dynamic: streams at request time */}
<Suspense fallback={<p>Loading your dashboard...</p>}>
<PainelUsuario />
</Suspense>
</>
)
}
async function Ofertas() {
'use cache'
cacheLife('hours')
const res = await fetch('https://api.exemplo.app/ofertas')
const ofertas = await res.json()
return (
<ul>
{ofertas.map((o) => (
<li key={o.id}>{o.titulo}</li>
))}
</ul>
)
}
async function PainelUsuario() {
const tema = (await cookies()).get('tema')?.value || 'light'
return <aside>Theme: {tema}</aside>
}The detail that cost me time: does not opt the component into dynamic rendering by itself. If PainelUsuario only did synchronous work, it would complete during prerendering even inside the boundary. It's the access to cookies() that triggers streaming. Reading the cookie here, with the boundary in place, stops dragging the whole route into dynamic rendering the way it did in the old model.
Step 4: push the await further down the tree
This was the gain that wasn't obvious. I had a layout that did await params at the top, and that prevented the shell from rendering. The rule the docs reinforce: the deeper the async work sits, the more of the page you can prerender.
Instead of await in the layout, I passed the promise down and resolved it inside the boundary:
export default function Layout({ children, params }) {
return (
<div>
<Sidebar />
<Suspense fallback={<h1>Loading...</h1>}>
{params.then(({ slug }) => (
<SlugHeading slug={slug} />
))}
</Suspense>
{children}
</div>
)
}Now Sidebar, children, and the fallback are part of the shell. Only the heading with the slug streams.
What changes before and after
The point of PPR isn't a magic number, it's where the cost falls. Before, with cookies() sitting loose, the browser waited for the server to resolve the read before sending any HTML: TTFB carried the cost of the whole route. After the migration, the shell (header + cached deals + the dashboard fallback) is served directly from the CDN, without going through the upstream server, so direct navigation is instant. The user's dashboard stays fresh, except now it appears via streaming behind the fallback, without holding up the rest of the page.
That's why the documentation calls it a static shell served by the CDN: the content above the fold stops depending on the request. Measure your own route before and after with Lighthouse to see the real delta, which varies by backend and provider.
One real caveat: cache generated from request data (the use cache: private pattern, or values extracted and passed down as props) stays in memory by default and doesn't survive across serverless requests. If you need durable, shared cache, there's use cache: remote.
Final accessibility tip: use fallbacks that don't cause layout shift. A Loading... that disappears and gets replaced by a larger block hurts CLS. Reserve the skeleton's space at the same height as the final content. Always measure before you celebrate.
Translated from the Brazilian Portuguese original · Read the original
Jev turns design system into a decision engine for AI agents
TypeSafe AI's model doesn't generate interface: it chooses among options you define. This changes what it means to maintain a design system.

