Headline: Partial Prerendering (PPR) in Next.js serves a static HTML shell from the CDN edge instantly, then streams Suspense-wrapped dynamic children from the origin in the same HTTP response. No full-page ISR staleness, no full-page origin latency. I shipped it on two production routes â here is the model.
Key takeaways
- PPR serves a static HTML shell from the CDN edge, then streams dynamic Suspense children from the origin in the same response.
-
The static shell is built at build time â outside
<Suspense>renders statically; inside renders dynamically per request. - PPR replaces the ISR vs. dynamic tradeoff for pages that are mostly static with isolated personalized sections.
-
No changes to Server Components or Suspense â just
experimental.ppr: 'incremental'in config andexport const experimental_ppr = trueper route. -
PPR and
use cacheare complementary: CDN delivery for the shell, origin memoization for dynamic islands.
What does PPR actually do?
PPR splits a page into two rendering phases within the same HTTP response. At build time, Next.js freezes everything that does not read dynamic request data into a static HTML shell on the CDN edge. At request time, the CDN delivers the shell at edge latency while the origin streams each <Suspense> boundary's content into the same response.
On a product page: navigation, title, and description arrive at CDN speed. The in-stock badge and personalized recommendations stream from the origin a fraction of a second later. The user sees a nearly-complete page immediately.
How is PPR different from ISR and streaming Suspense?
| Strategy | First byte | Dynamic freshness | Staleness |
|---|---|---|---|
| ISR (revalidate: N) | CDN edge | Whole page up to N seconds stale | Full page |
| Dynamic rendering | Origin | 100% fresh; waits for slowest query | None |
| Streaming Suspense (no PPR) | Origin | Fresh; TTFB includes origin latency | None |
| PPR | CDN edge | Dynamic islands 100% fresh | Static shell only |
How do I enable PPR?
// next.config.ts
export default { experimental: { ppr: 'incremental' } };
// app/products/[slug]/page.tsx
export const experimental_ppr = true;
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return (
<main>
<ProductHeader slug={slug} /> {/* static shell â CDN */}
<Suspense fallback={<StockSkeleton />}>
<StockAvailability slug={slug} /> {/* dynamic â origin */}
</Suspense>
</main>
);
}
Anything outside <Suspense> must not read dynamic data. If it calls cookies(), the build fails with a clear error.
What belongs in the shell vs. inside Suspense?
Static shell: navigation, footer, static content, layout structure, images independent of the user's session.
Inside Suspense: user-specific content (cart, recommendations), real-time inventory or pricing, A/B cookie reads, any component calling cookies(), headers(), or searchParams.
How do PPR and use cache compose?
'use cache';
import { cacheLife, cacheTag } from 'next/cache';
export async function ProductDescription({ slug }: { slug: string }) {
cacheLife('minutes');
cacheTag(`product-desc-${slug}`);
const desc = await db.product.findUnique({ where: { slug }, select: { description: true } });
return <p>{desc?.description}</p>;
}
PPR controls the CDN boundary. use cache memoizes origin renders inside Suspense. Both stack: CDN for the shell, component cache for the dynamic parts.
When should I use PPR vs. full dynamic rendering?
Use PPR when the page is mostly static with isolated dynamic sections â a product page with real-time inventory, a SaaS dashboard with a cached sidebar plus live metrics. If the entire page depends on per-request data, full dynamic rendering is simpler.
FAQ
Q: Does PPR require changes to Server Components or Suspense?
A: No. Only the config flag and per-route export. All existing patterns work unchanged.
Q: What happens to SEO with PPR?
A: The static shell is fully crawlable HTML from the CDN. Suspense-streamed content may or may not be indexed by crawlers. Keep title, description, and primary body text in the static shell.
Q: Can I use PPR and ISR on the same route?
A: No. A route opts into either PPR or ISR. PPR replaces ISR: static shell is the build-time snapshot, dynamic Suspense children are always fresh.
Q: Is PPR stable in Next.js 15 and 16?
A: Incremental opt-in, experimental through Next.js 16. Production-safe (Vercel uses it internally) but API may change before graduating to stable.
Q: How do I verify PPR is working?
A: Run next build. Routes with PPR show a split between static and dynamic segments in terminal output. In Chrome DevTools Network, the response arrives in chunks: static shell first, then the Suspense stream.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)