For years the rendering decision in Next.js was binary: either pre-render the page at build time (SSG) or render it fresh on every request (SSR). The problem is that most real pages don't fit cleanly into either bucket.
A product page has a static title and description (same for everyone), a live stock count (changes every few minutes), and a personalized recommendations section (different per user). With classic Next.js you pick one strategy for the whole page and compromise everywhere else.
Partial Prerendering fixes this. It renders the static parts at build time and fills in the dynamic parts at request time — on the same page, without workarounds.
How PPR Works
PPR uses <Suspense> boundaries to declare which parts of a page are dynamic. Everything outside a Suspense boundary is the static shell — pre-rendered at build time and cached at the edge. Everything inside a Suspense boundary that reads request-time data is a dynamic hole — rendered fresh at request time and streamed to the client.
Build time ┌───────────────────────────────┐
│ Header (static) │ ← edge-cached HTML
│ Product name + description │ ← edge-cached HTML
│ ┌─────────────────────────┐ │
│ │ <Suspense> │ │ ← rendered fresh per request
│ │ Stock count · Price │ │
│ └─────────────────────────┘ │
└───────────────────────────────┘
Users get the static shell from the CDN instantly. Dynamic content streams in right behind it.
Enabling PPR
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental', // opt-in per route
},
}
export default nextConfig
Then opt in on each route:
// app/products/[id]/page.tsx
export const experimental_ppr = true
export default function ProductPage({ params }: { params: { id: string } }) {
// ...
}
Building a PPR Page
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { ProductDetails } from './product-details'
import { StockBadge } from './stock-badge'
import { PersonalizedSection } from './personalized-section'
export const experimental_ppr = true
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<main className="container mx-auto py-8">
{/* Static — pre-rendered at build time */}
<ProductDetails productId={params.id} />
{/* Dynamic — stock changes constantly */}
<Suspense fallback={<div className="h-8 w-32 animate-pulse bg-muted rounded" />}>
<StockBadge productId={params.id} />
</Suspense>
{/* Dynamic — personalized per user */}
<Suspense fallback={<PersonalizedSkeleton />}>
<PersonalizedSection productId={params.id} />
</Suspense>
</main>
)
}
The static component
// product-details.tsx — runs at BUILD TIME
import { db } from '@/lib/db'
import { notFound } from 'next/navigation'
export async function ProductDetails({ productId }: { productId: string }) {
const product = await db.query.products.findFirst({
where: (p, { eq }) => eq(p.id, productId),
})
if (!product) notFound()
return (
<div className="space-y-4">
<h1 className="text-3xl font-bold">{product.name}</h1>
<p className="text-muted-foreground">{product.description}</p>
<p className="text-2xl font-semibold">${product.price.toFixed(2)}</p>
</div>
)
}
The dynamic components
// stock-badge.tsx — runs at REQUEST TIME
import { unstable_noStore as noStore } from 'next/cache'
import { getInventoryLevel } from '@/lib/inventory'
export async function StockBadge({ productId }: { productId: string }) {
noStore() // force fresh rendering every request
const stock = await getInventoryLevel(productId)
if (stock === 0) return <span className="text-destructive font-medium">Out of stock</span>
if (stock <= 5) return <span className="text-amber-600 font-medium">Only {stock} left</span>
return <span className="text-emerald-600 font-medium">In stock</span>
}
// personalized-section.tsx — dynamic because of cookies()
import { cookies } from 'next/headers'
import { getRecommendations } from '@/lib/recommendations'
export async function PersonalizedSection({ productId }: { productId: string }) {
const cookieStore = cookies() // makes this component dynamic automatically
const userId = cookieStore.get('user_id')?.value
const recs = await getRecommendations({ productId, userId })
return (
<section className="mt-12">
<h2 className="text-xl font-semibold mb-4">
{userId ? 'Recommended for you' : 'You might also like'}
</h2>
<div className="grid grid-cols-4 gap-4">
{recs.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
</section>
)
}
Calling cookies() automatically makes the component dynamic — no need for noStore() when you're already reading request-time data.
Deduplicate Requests with cache()
When multiple components need the same data, use React's cache():
// lib/queries/product.ts
import { cache } from 'react'
import { db } from '@/lib/db'
export const getProduct = cache(async (id: string) => {
return db.query.products.findFirst({
where: (p, { eq }) => eq(p.id, id),
})
})
Both static and dynamic components can call getProduct(id) — only one database query runs per render cycle.
Suspense Fallbacks That Don't Shift Layout
Design fallbacks that match the real content's dimensions:
function PersonalizedSkeleton() {
return (
<div className="grid grid-cols-4 gap-4 mt-12">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="aspect-square rounded-lg animate-pulse bg-muted" />
<div className="h-4 w-3/4 rounded animate-pulse bg-muted" />
<div className="h-4 w-1/2 rounded animate-pulse bg-muted" />
</div>
))}
</div>
)
}
Nested Suspense Boundaries
Control the order of streaming with nesting:
export default function CheckoutPage() {
return (
<main>
<CheckoutForm /> {/* static */}
<Suspense fallback={<OrderSummarySkeleton />}>
<OrderSummary />
{/* resolves after OrderSummary — needs order data + location */}
<Suspense fallback={<ShippingEstimateSkeleton />}>
<ShippingEstimate />
</Suspense>
</Suspense>
{/* resolves independently */}
<Suspense fallback={<PaymentSkeleton />}>
<PaymentSection />
</Suspense>
</main>
)
}
OrderSummary streams in when cart data resolves. ShippingEstimate and PaymentSection resolve independently — none of them block each other.
PPR vs Other Strategies
| SSG | ISR | SSR | PPR | |
|---|---|---|---|---|
| Build time work | Full page | Full page | None | Static shell |
| Request time work | None | None | Full page | Dynamic holes |
| Fresh dynamic data | No | After revalidation | Yes | Yes |
| Personalization | No | No | Yes | Yes |
| Edge cache | Full page | Full page | Varies | Shell only |
Isolating searchParams
Reading searchParams forces the entire page dynamic. Isolate them to preserve a static shell:
// app/products/page.tsx
export const experimental_ppr = true
export default function ProductsPage({
searchParams,
}: {
searchParams: { q?: string; category?: string }
}) {
return (
<main>
{/* Static — no searchParams dependency */}
<FeaturedBanner />
{/* Dynamic — only this component reads searchParams */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid searchParams={searchParams} />
</Suspense>
</main>
)
}
What Makes a Component Dynamic (Checklist)
If a component you expect to be static is rendering dynamically, check for:
- ❌ Calling
cookies()orheaders()anywhere in its tree - ❌ Reading
searchParamsdirectly on the page - ❌ Calling
unstable_noStore()explicitly - ❌ Using
Date.now()orMath.random()in the render path - ❌ A third-party component that internally calls any of the above
Run npm run build and check the output — routes with PPR show as ◐ (PPR) with the static shell size listed.
When to Use PPR
PPR makes the most sense when your page has a meaningful static shell worth caching:
✅ Product pages — static description + dynamic stock/price/recommendations
✅ Blog posts — static content + dynamic comment count or reactions
✅ Dashboards — static navigation and layout + dynamic data widgets
✅ Landing pages — static hero/content + dynamic logged-in state
Skip PPR when everything is user-specific (use SSR) or nothing changes (use SSG).
Full article with more patterns at stacknotice.com/blog/nextjs-partial-prerendering-2026
Top comments (8)
The searchParams isolation tip is the one I'd flag for anyone trying PPR. It's easy to design a perfect static shell and then read searchParams at the top of the page, which quietly drags the whole route dynamic and undoes the win. Pushing that read down into the one component that needs it keeps the shell cacheable, and it's the kind of thing you'd only learn from a confusing build output. The cookies() and headers() gotcha sits in the same bucket, where one innocent call decides the rendering mode for everything above it.
The "infection" mental model is exactly right, and it's worth making explicit for anyone learning PPR.
cookies(), headers(), and searchParams don't just make the component they're used in dynamic. They make everything from that point up to the nearest Suspense boundary dynamic. It's not a component-level decision—it's a boundary-level decision.
The build output is usually the easiest place to spot when this has happened, although it's not always obvious what you're looking for. If a route you expected to be partially prerendered ends up being fully dynamic, that's usually a sign that some request-time data is being read above the Suspense boundary.
The solution is almost always the same: find the call, move it lower in the tree, and wrap it in its own Suspense boundary. Once the dynamic read is isolated, the rest of the page can remain part of the static shell.
One other detail that's easy to miss: params from dynamic route segments (for example, [id]) behaves differently from searchParams. Using params does not automatically force dynamic rendering. A lot of developers assume it does and end up avoiding route parameters unnecessarily, when in reality they're perfectly compatible with a static shell.
Once you start thinking in terms of boundaries rather than components, most of the "why did this route become dynamic?" mysteries become much easier to reason about.
PPR is useful because most real pages are mixed: stable shell, semi-live data, and personalized pieces. Treating the whole page as either static or dynamic was always too blunt.
Exactly — and I think the "too blunt" problem was hiding in plain sight for a while.
The workaround most teams settled on was client-side fetching for the dynamic parts. It works, but it means users get a static shell with empty placeholders that only fill in after hydration. PPR reaches the same end result, except those dynamic sections are rendered and streamed from the server, avoiding the client-side waterfall entirely.
The trickiest part in practice is figuring out where the static shell ends.
searchParamsis probably the most common trap: a singlesearchParamsread anywhere in the tree can cause the entire page to fall out of the static shell.The fix is usually straightforward once you know what's happening—move that logic into its own Suspense-wrapped component. But it's not obvious the first time you run into it, especially because the page still works; it just silently loses the rendering behavior you expected.
That boundary is the hard design work. PPR is strongest when the team can name which parts are stable product surface and which parts are live experience. Without that split, it is easy to recreate the old client-side waterfall in a fancier shape.
That framing is precise — "stable product surface vs live experience" is actually a better question to
ask than "static vs dynamic," because it maps to how product people think rather than just how engineers
think. A PM can usually answer it faster than a developer looking at component trees.
The waterfall risk you're describing happens most often with nested Suspense where the inner component has a data dependency on the outer one. The outer boundary resolves, the inner component mounts and fires its own fetch, and you've recreated a sequential round trip — just on the server side instead of the client. PPR doesn't fix that automatically; you still have to think about whether the data dependencies
are actually sequential or just accidentally coupled.
A heuristic I've started using: if you can answer "what does this page show to a visitor with no cookies and no session?" you've identified your static shell. Everything that would change with an actual user in the picture is a dynamic hole. It's a quick gut-check that separates the architectural question from the implementation details.
That heuristic is strong. The no-cookies/no-session question makes the boundary product-readable instead of framework-readable. I would add one more check: if the dynamic hole fails or gets slow, does the static shell still communicate the page's purpose? PPR is much easier to reason about when degradation is part of the design, not a fallback accident.
That resilience check reframes Suspense fallbacks in a really useful way. Most teams treat them as loading states, but they're actually part of the product's failure and degradation strategy.
An empty div, spinner, or generic skeleton isn't meaningful degradation. If the dynamic section is slow, errors out, or never arrives, the user is left staring at a placeholder that provides no context. The page technically loads, but it doesn't communicate anything useful.
A well-designed fallback preserves the page's intent. The user should still understand where they are, what the page is about, and what action they're expected to take, even if the dynamic content never renders.
A simple test is to hide all dynamic content and show only the static shell plus Suspense fallbacks. If a designer can immediately explain the purpose of the page and the user's next step, the architecture is resilient. If it looks like a collection of loading indicators waiting for "the real page" to arrive, then too much of the page's structure depends on dynamic rendering.
This also changes how you think about PPR boundaries. The goal isn't just to maximize the static shell. It's to ensure the shell contains the information necessary for the experience to remain coherent when the dynamic pieces are delayed.
There's a parallel here with API design. Good APIs fail gracefully and return meaningful errors. Good Suspense boundaries degrade gracefully and return meaningful UI. In both cases, the fallback path deserves almost as much design attention as the happy path.
That's why treating fallback components as throwaway skeletons is often a mistake. They're not implementation details; they're part of the user experience contract. If a dynamic section disappears, the fallback becomes the product.