All articles
Web DevelopmentAugust 31, 2026 7 min read

Partial Prerendering in Production: What Actually Ships and What Breaks

Partial Prerendering promised the best of static and dynamic. After shipping it on a few real apps, here's what actually works, what silently degrades, and where the sharp edges hide.

Partial Prerendering in Production: What Actually Ships and What Breaks

Partial Prerendering (PPR) is the feature that finally makes the static-vs-dynamic argument feel outdated. You get a prerendered shell served instantly from the edge, with dynamic holes streamed in from the runtime. On paper, it's the answer. In practice, we've shipped it on three production apps in the last year, and the story is more interesting than the marketing suggests.

This is a field report: what PPR actually does when you turn it on, where the Suspense boundaries end up mattering more than you'd expect, and the failure modes we've hit that don't show up in the docs.

What PPR is actually doing under the hood

When you enable PPR, Next.js does a build-time render of your route. Anywhere it hits a dynamic API — cookies(), headers(), searchParams, an uncached fetch — it needs a Suspense boundary above the call site. The prerender captures everything outside those boundaries as static HTML, and the boundaries themselves become holes with their fallback UI baked in. At request time, the static shell ships immediately and the dynamic holes stream in.

The mental model that finally clicked for our team: PPR is not "the page is static or dynamic". PPR is "the page is static, and Suspense boundaries are the dynamic parts". Everything hinges on where you draw those boundaries.

The config that trips people up

PPR is still incremental as of Next.js 15.x. You opt in per-route:

// next.config.ts
import type { NextConfig } from 'next';

const config: NextConfig = {
  experimental: {
    ppr: 'incremental',
  },
};

export default config;
// app/product/[slug]/page.tsx
export const experimental_ppr = true;

export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  // ...
}

The gotcha: setting experimental_ppr = true on a route with no Suspense boundaries and dynamic APIs at the top just falls back to a fully dynamic render. No error, no warning in production builds. You'll swear PPR is on. It isn't doing anything for you.

The Suspense boundary is a product decision, not a technical one

This is the biggest shift. Where you place <Suspense> decides what your users see in the first 100ms. Put it too high, and half the page is a skeleton. Put it too low, and you're prerendering data that changes per user, which forces the parent to become dynamic anyway.

Here's the pattern that's worked for us on e-commerce product pages:

// app/product/[slug]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from './product-details';
import { PriceAndStock } from './price-and-stock';
import { Recommendations } from './recommendations';
import { PriceSkeleton } from './skeletons';

export const experimental_ppr = true;

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  return (
    <article>
      {/* Static: product copy, images, specs. Prerendered. */}
      <ProductDetails slug={slug} />

      {/* Dynamic: personalised price, live stock. Streamed. */}
      <Suspense fallback={<PriceSkeleton />}>
        <PriceAndStock slug={slug} />
      </Suspense>

      {/* Dynamic: recommendations based on cookies. Streamed. */}
      <Suspense fallback={null}>
        <Recommendations />
      </Suspense>
    </article>
  );
}

Product copy and imagery are cached at build (or ISR-refreshed). Price and stock read cookies for the visitor's currency and warehouse region — those go behind a boundary. Recommendations read a session cookie — same treatment.

The result on our staging environments: TTFB drops close to the CDN's round trip because the shell is static, and LCP tends to hit before the dynamic holes resolve, provided your LCP element sits in the static shell. Which brings us to the sharpest edge.

If your LCP element is dynamic, PPR won't save you

We learned this the hard way. A team put the hero image inside a Suspense boundary because the image URL was personalised. LCP got worse than the fully-dynamic baseline, because now there was a skeleton flashing before the image, and the browser couldn't preload it from the initial HTML.

Rule we now follow: the LCP candidate must be in the static shell. If personalisation forces it dynamic, either denormalise the data so a static variant can render first, or accept that PPR isn't the right pattern for that page.

The dynamic API leak

PPR relies on the compiler catching every dynamic API and demanding a Suspense boundary above it. Most of the time it does. But we've hit two cases where it silently propagated dynamism up the tree:

  1. A third-party library calling headers() internally. An analytics helper we wrapped in a server component was reading the user-agent header. It wasn't wrapped in Suspense because we didn't know it was dynamic. The whole route quietly rendered dynamically in production. Caught it only when TTFB regressed on a routine deploy.
  2. A shared layout using cookies() for feature flags. Layouts are shared across routes. One cookies() call in a layout makes every child route dynamic above its own boundaries.

The fix for both: audit your server components with a lint rule or a grep pass before enabling PPR on a route. Anything reading cookies, headers, draftMode, or performing an uncached fetch needs a boundary above it — including transitively through libraries.

# quick audit
grep -rE "(cookies|headers|draftMode)\(\)" app/ lib/ components/

Data fetching: the use cache interplay

With the 'use cache' directive (still experimental at time of writing), the story gets cleaner. You can mark a function or component as cacheable, and PPR will happily prerender it:

import { unstable_cacheLife as cacheLife } from 'next/cache';

async function getProduct(slug: string) {
  'use cache';
  cacheLife('hours');

  const res = await fetch(`${process.env.API}/products/${slug}`);
  if (!res.ok) throw new Error('Product fetch failed');
  return res.json();
}

What we didn't expect: mixing 'use cache' and uncached fetches in the same server component silently makes the whole component dynamic. The cached call gives you nothing. If you want the cacheable part prerendered, factor it into its own component and keep the uncached call behind a Suspense boundary.

The revalidation story

PPR plays nicely with revalidateTag and revalidatePath. When you revalidate, the static shell gets regenerated on the next request; the dynamic holes were never cached to begin with, so they don't need touching. This is genuinely the cleanest revalidation model we've worked with — but only if you're disciplined about tagging. Untagged fetches become invisible to your invalidation code, and you'll end up rebuilding the whole route to bust one product's cache.

Observability: how do you even know PPR is working?

This is the part nobody talks about. In production, a PPR route and a dynamic route look identical from the outside. Same URL, same HTML shape, same status code. You need to instrument it yourself.

What we do:

  • Log the render mode. Add a header in middleware or a debug endpoint that reports whether the response came from prerender or dynamic render. Next.js emits x-nextjs-cache and related headers — pipe them into your log aggregator.
  • Track TTFB per route, not per app. A single dynamic leak can regress one route while the app-wide average looks fine. Split your RUM data by pathname.
  • Alert on Suspense fallback duration. If a dynamic hole takes 2 seconds to stream, users see the skeleton for 2 seconds. That's a UX bug even if the LCP number looks fine.

When PPR isn't the right answer

We've stopped reaching for PPR on:

  • Auth-gated dashboards where nearly the entire page is per-user. There's no meaningful static shell. A traditional dynamic render with aggressive caching wins.
  • Highly interactive apps (editors, canvases) where the shell is essentially a client-rendered app anyway. PPR adds config surface for no benefit.
  • Routes with fewer than ~1k views per day. The engineering cost of getting the boundaries right isn't worth it below that volume. Ship dynamic, revisit later.

PPR shines on marketing pages, product detail pages, listing pages, and content pages with a personalised sidebar or price. The shape that pays off is: mostly-static content, a few user-specific holes.

Where we'd start

If you're picking this up on an existing App Router codebase:

  1. Pick one high-traffic route with clear static and dynamic zones. Not your dashboard. A product page or a marketing landing page.
  2. Grep for cookies, headers, draftMode, and uncached fetch calls in the route's subtree, including shared layouts and libraries. Wrap each in Suspense at the right level.
  3. Confirm your LCP element sits in the static shell. If it doesn't, fix that before enabling PPR.
  4. Turn on experimental_ppr for that single route. Deploy to staging. Diff TTFB and LCP against the baseline.
  5. Only then roll it out further.

PPR is one of the few Next.js features where the performance win is real and measurable — but only when the Suspense boundaries reflect an actual product decision about what users need to see first. Get that right and it's the best rendering model we've shipped on. Get it wrong and it's a slower, more complicated dynamic render. If you want a hand structuring your App Router routes for this, our web development team does this work with clients regularly.

#Next.js#React#Performance#App Router#PPR

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project