Next.js Partial Prerendering (PPR) in Production: What Works
By Nayan Kyada · · 6 min read
Part of Next.js Performance
Next.js partial prerendering (PPR) is the most interesting rendering primitive the framework has shipped in years. The pitch is simple: render a static shell at build time, stream dynamic holes at request time. In production, though, the story has more friction than the docs let on.
What PPR actually solves
The problem PPR targets is real. Before PPR, you had an ugly fork in the road: either mark the entire route dynamic = 'force-dynamic' and lose every caching benefit, or commit to fully static and hack around personalized content with client-side fetches that tank your LCP.
PPR dissolves that fork. The HTML shell — nav, hero, structured content — is served straight from the cache, typically from a CDN edge. <Suspense> boundaries containing dynamic content (cart count, user greeting, live stock level) resolve server-side and stream in milliseconds later. The browser paints something useful immediately, then progressively fills in the rest.
For pages that are 80% static and 20% personalized, this is the right model. Marketing landing pages with a logged-in nav, product detail pages with live inventory, blog posts with a recommended-articles widget — all fit naturally.
Enabling PPR and what changes in your data fetching
In Next.js 15, PPR was an experimental, per-route opt-in (export const experimental_ppr = true). Next.js 16 removed that flag: you now enable PPR for the whole app with Cache Components:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;With Cache Components on, data fetching is dynamic by default. Anything you want in the static shell has to be cached explicitly with 'use cache':
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
import { cacheTag } from 'next/cache';
async function getProduct(slug: string) {
'use cache';
cacheTag(`product:${slug}`);
// ...fetch from Sanity or your commerce API
}
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
// Cached via 'use cache' — part of the static shell
// (list known slugs in generateStaticParams so params are known at build time)
const { slug } = await params;
const product = await getProduct(slug);
return (
<main>
<ProductHero product={product} />
{/* Dynamic boundary — resolves at request time */}
<Suspense fallback={<InventorySkeletonUI />}>
<LiveInventory productId={product._id} />
</Suspense>
</main>
);
}The dynamic work happens inside LiveInventory. Its fetch isn't cached, so it runs at request time and streams into the Suspense boundary:
// app/products/[slug]/_components/LiveInventory.tsx
import { connection } from 'next/server';
export async function LiveInventory({ productId }: { productId: string }) {
// Signals to Next.js: this component runs at request time
await connection();
const res = await fetch(`https://inventory-api.example.com/stock/${productId}`, {
cache: 'no-store',
});
const { available } = await res.json();
return <span>{available} in stock</span>;
}connection() is the explicit signal that replaces the older cookies() / headers() trick for opting a component into request-time rendering. With Cache Components the uncached fetch is already dynamic, so connection() here mostly documents intent — it becomes necessary when a component has no data access but still must run per request (for example, it reads the current time or generates a random value).
Where PPR breaks down in production
Fallback quality matters enormously. The static shell is what users see first. If your Suspense fallback is a raw blank div, CLS will spike when dynamic content streams in. You need accurate skeleton dimensions — ideally matching the known content size from your static data. For Sanity-powered content where image dimensions are in the query response, this is tractable. For unknown user data it's harder.
Slow origins still bite you. The dynamic portions run in your server functions at request time. If your origin — a Sanity query, an inventory API — isn't fast and geographically close, the streaming advantage shrinks. The static shell can render almost instantly while the dynamic stream takes most of a second; in that case PPR doesn't remove the latency, it just moves it into a loading skeleton.
Not all Suspense boundaries are PPR boundaries. This is an easy one to trip over. Client components wrapped in Suspense still do their data fetching on the client. PPR's request-time streaming applies when the component inside Suspense is a Server Component doing uncached data access or using runtime APIs (cookies(), headers(), connection()). If you drop a client component in there expecting server-side streaming, you get CSR — same as before, just with extra routing ceremony.
Leftover route segment config. When you turn on Cache Components in Next.js 16, the old segment options — dynamic = 'force-dynamic', revalidate, fetchCache — are no longer available, and dynamicParams isn't either. Audit every page and layout for them during migration; an older codebase usually has a few forgotten force-dynamic exports, and their caching intent has to be re-expressed with 'use cache' and cacheLife.
Page patterns that benefit most
I'd use PPR on these patterns without hesitation:
- Long-form content pages (blog, docs, product details) where 90% of the HTML is static and you have one or two personalized widgets bolted on.
- Category / listing pages where filters and facets are URL-driven (static) but cart state or "saved" indicators are user-specific.
- Homepage with a CMS hero — the content block is fully static from Sanity, but a promotional banner keyed to the user's region streams in dynamically.
I'd avoid PPR, or approach it carefully, on:
- Fully personalized dashboards where less than 30% of the page is static. The overhead of splitting isn't worth it; just use dynamic rendering end to end.
- Pages where accurate fallback skeletons are impossible — if you don't know the shape of the content, your CLS will get worse, not better.
- Codebases mid-migration from the old caching model — finish moving segment config to
'use cache'before relying on PPR.
My honest take
PPR is a good idea that isn't fully baked yet in the production tooling. The rendering model is correct. The developer experience — diagnosing why a component ended up static versus dynamic, migrating old segment config, reasoning about 'use cache' versus connection() — still requires too much tribal knowledge.
If you're building a Sanity-powered marketing site, the shell-plus-dynamic model maps well onto how Sanity content is structured: structured document fields go in the static shell, real-time or user-specific widgets go behind Suspense. For that use case I'd adopt PPR today. For anything with more complex dynamism, I'd wait until the observability tooling catches up.
Frequently asked questions
01How do I mark a Server Component as dynamic for PPR in Next.js 16?
With `cacheComponents` enabled, uncached data fetching is dynamic by default — anything not marked with `'use cache'` is excluded from the prerendered shell and must sit inside a `<Suspense>` boundary. If a component has no data access but must still run per request (for example, it uses the current time), call `await connection()` from `next/server` to make that explicit.
02Why isn't PPR working on my Next.js page even though I set experimental_ppr = true?
Next.js 16 removed the `experimental_ppr` route segment config and the `experimental.ppr` flag. PPR is now enabled app-wide with `cacheComponents: true` in `next.config.ts`, and route segment options like `dynamic`, `revalidate` and `fetchCache` are no longer available once it's on — caching is controlled with `'use cache'`, `cacheLife` and `cacheTag` instead. Also check that the component inside your `<Suspense>` boundary is a Server Component doing the dynamic work; a Client Component in that slot just renders on the client as before.