DEV Community

Cover image for Next.js partial prerendering in production: an honest assessment
Nayan Kyada
Nayan Kyada

Posted on • Originally published at nayankyada.com

Next.js partial prerendering in production: an honest assessment

Next.js partial prerendering (PPR) in production is one of those features that looks elegant in a demo and then humbles you the moment you apply it to a real content site with auth, personalisation, and third-party scripts. I have been running PPR on a handful of client projects since it moved out of experimental in Next.js 15, and this is what I actually think.

What PPR is trying to solve

The honest framing: PPR is a compromise between static and dynamic rendering, not a replacement for either. The classic problem it targets is a page that is 90% cacheable — a marketing landing page, a product detail page, a blog post — but has one or two dynamic fragments: a user-specific cart count, a personalised banner, a recently-viewed shelf. Before PPR, a single cookies() or headers() call anywhere in the render tree opted the entire route into dynamic rendering, destroying your TTFB.

PPR lets you stream a static shell (prerendered at build time, served from the edge instantly) and defer the dynamic parts behind <Suspense> boundaries, which are filled in via a streaming response once the dynamic work resolves. For content-heavy pages with a small dynamic surface, this is genuinely useful.

Where it actually works well

The page patterns that benefit most are exactly what you would expect:

  • Product pages with static copy and a dynamic price/stock fragment
  • Blog posts served from a CMS (Sanity, in my case) with a personalised "save for later" button
  • Marketing landing pages with a logged-in user greeting or A/B variant in a single section

The gains are real. A Sanity-powered product page that previously hit ~300ms TTFB due to a single cookies() call in a nav component drops back to sub-50ms for the shell. The user sees content immediately; the dynamic fragment streams in within a couple hundred milliseconds. That is a meaningful LCP improvement without restructuring the whole page.

The Suspense boundary problem

Here is where developers hit a wall. PPR does not automatically know which parts of your tree are dynamic — you have to wrap dynamic components in <Suspense> and ensure the static outer shell contains no dynamic calls. That sounds simple until you realise how many components call cookies() or headers() indirectly, often through a shared auth utility or a third-party wrapper.

// app/products/[slug]/page.tsx
import { Suspense } from 'react'
import { ProductDetails } from '@/components/product-details' // static — reads from Sanity
import { CartButton } from '@/components/cart-button' // dynamic — reads cookies
import { CartButtonSkeleton } from '@/components/cart-button-skeleton'

export const experimental_ppr = true

export default async function ProductPage({ params }: { params: { slug: string } }) {
  return (
    <main>
      {/* This component fetches from Sanity — fully static, prerendered */}
      <ProductDetails slug={params.slug} />

      {/* CartButton calls cookies() internally — must be inside Suspense */}
      <Suspense fallback={<CartButtonSkeleton />}>
        <CartButton />
      </Suspense>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

The problem I run into most often: a layout component that seemed static actually calls headers() to read an accept-language header for locale detection. That single call poisons the entire shell. You have to audit every component in the static portion of the tree, and in a real codebase that audit takes time.

Next.js will tell you at build time if a dynamic API is used outside a Suspense boundary when PPR is enabled — but the error messages are not always specific enough to pinpoint the offending import chain quickly.

What changes in your data-fetching

The shift PPR demands is moving from "fetch data at the top of the page and pass it down" toward "fetch data as close to the component that needs it as possible". This aligns well with React Server Components anyway, but PPR makes it mandatory for anything that mixes static and dynamic data.

For Sanity-backed pages, this is mostly fine — fetch in an RSC is deduplicated by Next.js's request cache, so multiple components querying the same document do not trigger multiple round trips. The bigger adjustment is pulling dynamic reads (auth cookies, user preferences, request headers) into leaf components wrapped in Suspense, rather than resolving them in a shared root layout.

// lib/get-product.ts — called from a static RSC, no dynamic APIs here
import { client } from '@/sanity/client'

export async function getProduct(slug: string) {
  return client.fetch(
    `*[_type == "product" && slug.current == $slug][0]{
      _id, title, price, description, mainImage
    }`,
    { slug },
    // Tag for on-demand revalidation via Sanity webhook
    { next: { tags: [`product:${slug}`] } }
  )
}
Enter fullscreen mode Exit fullscreen mode

The next: { tags } option still works exactly as before with PPR — ISR tag-based revalidation invalidates the prerendered shell. That combination (PPR shell + webhook-triggered revalidation) is the pattern I reach for on Sanity content sites now.

Where PPR breaks down

Three situations where I would not use PPR:

Fully dynamic pages. If a page is personalised end-to-end — a dashboard, a checkout, anything behind auth — there is no static shell to speak of. PPR adds complexity with no payoff. Use dynamic rendering or a client-side data fetch.

Pages with many small dynamic fragments. If you have eight different Suspense boundaries each streaming in a small fragment, the waterfall of small streaming chunks can actually feel worse than a single dynamic response. The user watches the page assemble piece by piece. PPR works best when the dynamic surface is small and concentrated.

Teams without strong component discipline. PPR punishes implicit coupling. If your codebase has a habit of reading cookies() in utility functions called from many places, the refactor required before PPR is worthwhile is not trivial. I have quoted that work to clients as a discrete scope item, not a free upgrade.

My current position

PPR is worth enabling on content-heavy routes where a small dynamic fragment was the only thing blocking static rendering. It is not a silver bullet, and it is not as automatic as Vercel's blog posts make it sound. Treat it as a page-level decision, not a global configuration you flip on and walk away from. Audit your component tree first, plan your Suspense boundaries before writing code, and keep the dynamic surface as small as possible.

Top comments (0)