DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Next.js Partial Prerendering (PPR) Guide (2026)

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    │  │
            │  └─────────────────────────┘  │
            └───────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then opt in on each route:

// app/products/[id]/page.tsx
export const experimental_ppr = true

export default function ProductPage({ params }: { params: { id: string } }) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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),
  })
})
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

What Makes a Component Dynamic (Checklist)

If a component you expect to be static is rendering dynamically, check for:

  • ❌ Calling cookies() or headers() anywhere in its tree
  • ❌ Reading searchParams directly on the page
  • ❌ Calling unstable_noStore() explicitly
  • ❌ Using Date.now() or Math.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)

Collapse
 
nazar_boyko profile image
Nazar Boyko

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.

Collapse
 
stacknotice profile image
Carlos Oliva Pascual

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.

Collapse
 
alexshev profile image
Alex Shev

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.

Collapse
 
stacknotice profile image
Carlos Oliva Pascual

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. searchParams is probably the most common trap: a single searchParams read 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.

Collapse
 
alexshev profile image
Alex Shev

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.

Thread Thread
 
stacknotice profile image
Carlos Oliva Pascual

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.

Thread Thread
 
alexshev profile image
Alex Shev

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.

Thread Thread
 
stacknotice profile image
Carlos Oliva Pascual

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.