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 — ships from the edge cache in under 50 ms. <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
As of Next.js 15, PPR is still opt-in per layout or page:
// app/products/[slug]/page.tsx
export const experimental_ppr = true;
export default async function ProductPage({ params }: { params: { slug: string } }) {
// This fetch runs at build/revalidation time — static shell
const product = await getProduct(params.slug);
return (
<main>
<ProductHero product={product} />
{/* Dynamic boundary — resolves at request time */}
<Suspense fallback={<InventorySkeletonUI />}>
<LiveInventory productId={product._id} />
</Suspense>
</main>
);
}
The catch is inside LiveInventory. Any fetch() call there must opt out of caching explicitly, otherwise Next.js may still treat it as static:
// 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 dynamic rendering. Without it, if your fetch has any cache hit, Next.js may hoist the component into the static shell — and you'll silently serve stale personalized data.
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.
Cold starts on edge functions still bite you. The dynamic portions run as edge functions. If your origin — a Sanity CDN query, an inventory API — isn't also edge-fast, the streaming advantage shrinks. I've seen pages where the static shell renders in 40 ms but the dynamic stream takes 800 ms because the downstream API isn't geographically close. PPR didn't help; it just moved the latency into a loading spinner.
Not all Suspense boundaries are PPR boundaries. This tripped me up early. Client components wrapped in Suspense are still client-side only. PPR only applies when the component inside Suspense is a Server Component that calls connection() or uses dynamic APIs. If you drop a client component in there expecting server-side streaming, you get CSR — same as before, just with extra routing ceremony.
Route segment config conflicts. If any parent layout sets dynamic = 'force-dynamic', PPR is silently disabled for that subtree. I spent an afternoon debugging why a page wasn't getting the PPR treatment only to find a middleware-adjacent layout with a leftover force-dynamic from six months earlier. The framework gives you no warning.
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.
- Any route where a parent layout is already dynamic — until Next.js gives clearer conflict warnings, the debugging cost is too high.
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, debugging silent config conflicts, reasoning about connection() versus cookies() — 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.
Top comments (0)