Post 20

Partial Prerendering, Explained by Building One Page

PPR lets a single route be static where it can be and dynamic where it must be — one HTTP response, a prerendered shell with holes that stream in. Here is the model, and a product page built with it from scratch.

Aug 5, 2026/10 min readNext.js
ShareY
Partial Prerendering, Explained by Building One Page

For years the choice was per-route: this page is static, that page is dynamic. Real pages are not like that. A product page is 90% the same for everyone — name, description, images, spec table — and 10% personal: your price with your discount, whether it is in your cart, stock at your nearest store. Partial Prerendering (PPR) stops forcing that whole page to be dynamic because of the 10%.

The mental model

With PPR, Next.js prerenders the route at build time. Anywhere it hits a dynamic boundary — a <Suspense> around something that reads request data — it leaves a hole and remembers how to fill it. At request time you get one response: the static shell flushes immediately, the holes stream in as their data resolves.

Static RenderingDynamic RenderingPPR
Shell TTFBInstant (CDN)Slow (server)Instant (CDN)
Personalised contentNoYesYes (streamed)
HTTP requests111
Cache storySimpleNoneShell cached, holes fresh

Turn it on

next.config.ts
1import type { NextConfig } from "next";2 3const config: NextConfig = {4  experimental: {5    ppr: "incremental", // opt in per route while you migrate6  },7};8 9export default config;
app/product/[slug]/page.tsx
export const experimental_ppr = true; // this route uses PPR

Build the page: static by default

Everything that does not read cookies(), headers(), or searchParams is static. Write it exactly as you would a normal Server Component — the catalogue data is the same for everyone, so it prerenders.

app/product/[slug]/page.tsx
1import { Suspense } from "react";2import { getProduct } from "@/lib/catalogue";3import { LivePrice, PriceSkeleton } from "./live-price";4import { CartStatus } from "./cart-status";5 6export const experimental_ppr = true;7 8export default async function ProductPage({9  params,10}: {11  params: Promise<{ slug: string }>;12}) {13  const { slug } = await params;14  const product = await getProduct(slug); // static — same for everyone15 16  return (17    <article>18      <Gallery images={product.images} />19      <h1>{product.name}</h1>20      <p>{product.description}</p>21      <SpecTable specs={product.specs} />22 23      {/* --- the dynamic holes --- */}24      <Suspense fallback={<PriceSkeleton />}>25        <LivePrice sku={product.sku} />26      </Suspense>27 28      <Suspense fallback={null}>29        <CartStatus sku={product.sku} />30      </Suspense>31    </article>32  );33}

The holes: dynamic, behind Suspense

A component becomes a dynamic hole the moment it reads request-scoped data. The <Suspense> boundary around it is what tells PPR “prerender the fallback, stream the real thing”.

app/product/[slug]/live-price.tsx
1import { cookies } from "next/headers";2import { getPriceFor } from "@/lib/pricing";3 4export async function LivePrice({ sku }: { sku: string }) {5  const session = (await cookies()).get("session")?.value; // ← dynamic6  const { price, discount } = await getPriceFor(sku, session);7 8  return (9    <p className="price">10      {formatUsd(price)}11      {discount > 0 && <span> ({discount}% member price)</span>}12    </p>13  );14}15 16export function PriceSkeleton() {17  return <p className="price" aria-hidden><span className="shimmer w-24" /></p>;18}
Watch out

The skeleton must match the final layout's dimensions. If <PriceSkeleton> is 20px tall and the real price is 44px, the page shifts when the hole fills — you have traded a slow page for a janky one. This is CLS, and PPR makes it easy to introduce.

What you can and cannot do in the shell

  • Static shell: params, generateStaticParams, any fetch with caching, database reads that are not per-user, generateMetadata from catalogue data.
  • Must be a hole: cookies(), headers(), searchParams, draftMode(), uncached fetch, anything time-sensitive like stock counts.
  • Gotcha: reading a dynamic API *outside* a Suspense boundary opts the whole route back into full dynamic rendering. The build will warn you.

Why this matters for real products

The shell is served from the edge cache with an instant TTFB, so LCP is almost always the hero image loading, not a server round trip. The personalised parts stream in a few hundred milliseconds later, into space that was already reserved for them. You get the performance profile of a static site and the correctness of a dynamic one, from one route, with no client-side data fetching.

PPR is not a new rendering mode you have to learn. It is the framework finally letting a page be honest about which parts of it are actually personal.

Found this useful? Pass it on.

All posts

Want a custom write-up for your team? Get in touch.

Building something like this?

If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.