Partial Prerendering in Next.js 16: Static Shells, Dynamic Holes
Partial Prerendering combines a cached static shell with streamed dynamic content in one HTTP response — no layout shift, no separate loading state. Here's how to enable it and when to reach for it.
June 1, 2026 · 5 min read
Every Next.js route has historically been one of two things: fully static (cached at build time, served instantly from CDN) or fully dynamic (rendered on every request, as fast as your slowest query). Neither extreme fits most real pages. Your marketing header and nav are static. Your user-specific data — AI summaries, personalised recommendations, live inventory — is dynamic. Partial Prerendering, stable in Next.js 16, lets you have both on the same route in the same HTTP response.
What Partial Prerendering does
When a PPR-enabled route is requested, Next.js serves the static shell from cache immediately — the same sub-millisecond CDN hit you get for a fully static page. Simultaneously, it opens a streaming connection to fill each <Suspense> boundary whose fallback is already in the shell. Dynamic content replaces the fallbacks progressively over the same HTTP response; no second round-trip, no flash of layout shift between the cached shell and the live data.
The boundary between static and dynamic is just <Suspense>. Whatever React can prerender at build time becomes the shell; anything that suspends during prerendering becomes a dynamic hole. You do not need a new API — you need to draw your Suspense boundaries deliberately.
Enabling PPR
In Next.js 16, Partial Prerendering is opt-in at the route level via a segment config export. Enable it globally in next.config.ts to make PPR the default for new routes, or adopt it incrementally one page at a time:
// next.config.ts — enable PPR for all routes
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
ppr: true,
};
export default nextConfig;For incremental adoption, set ppr: 'incremental' in the config and opt individual routes in with export const experimental_ppr = true at the top of their layout or page file. This lets you migrate a large app without committing every route at once.
A concrete example
A product page where the layout is static but the AI-generated summary is dynamic looks like this:
// app/products/[slug]/page.tsx
import { Suspense } from "react";
import { ProductHero } from "@/components/ProductHero";
import { AISummary } from "@/components/AISummary";
import { AISummarySkeleton } from "@/components/AISummarySkeleton";
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug); // build-time cacheable
return (
<main>
{/* prerendered into the static shell at build time */}
<ProductHero product={product} />
{/* dynamic hole — fallback ships in the shell, real content streams in */}
<Suspense fallback={<AISummarySkeleton />}>
<AISummary slug={params.slug} />
</Suspense>
</main>
);
}ProductHero and the skeleton render at build time and are served from cache. AISummary calls your LLM at request time and streams in. The user sees the hero and the skeleton in under 50 ms. The AI content replaces the skeleton within the same response as soon as the first tokens are ready.
Why PPR matters specifically for AI-native apps
LLM inference is inherently dynamic — you cannot cache a personalised generation at build time. But the page around it (nav, header, product details, related links) often changes rarely. Without PPR, making the AI section dynamic forces the entire route dynamic, and every visitor pays the cold-start cost of your server rendering the static parts on every request. With PPR, the static majority is cached and the dynamic minority streams in. Total generation time for the AI call is unchanged, but time-to-first-byte drops to CDN latency.
This compounds with the use cache directive covered in our previous post. Cache the non-personalised parts of your AI pipeline with use cache, wrap the personalised leaf in a Suspense boundary, and PPR ensures the shell is always instantaneous.
Things to watch for
PPR requires that your dynamic holes actually suspend. If an async Server Component inside a Suspense boundary reads from cookies() or headers(), Next.js correctly treats it as dynamic and excludes it from the static shell. If it only reads from a cached source, it may be prerendered into the shell instead — which is the right outcome, but can surprise you if you expected it to be dynamic.
Also note: the streaming half of PPR requires a runtime that supports HTTP streaming. Vercel, Cloudflare Workers, and Node.js all do. Static export (output: 'export') does not — PPR is incompatible with fully static export since there is no server to handle the dynamic portion.
The bottom line
Partial Prerendering removes the all-or-nothing choice between static and dynamic. Draw your Suspense boundaries where data freshness actually requires it, enable ppr: true, and Next.js takes care of splitting the render. For AI-native apps where fast static layout and slow personalised content coexist on the same page, it is the most impactful single configuration change you can make.