Post 15
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.

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 Rendering | Dynamic Rendering | PPR | |
|---|---|---|---|
| Shell TTFB | Instant (CDN) | Slow (server) | Instant (CDN) |
| Personalised content | No | Yes | Yes (streamed) |
| HTTP requests | 1 | 1 | 1 |
| Cache story | Simple | None | Shell cached, holes fresh |
Turn it on
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;export const experimental_ppr = true; // this route uses PPRBuild 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.
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”.
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}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, anyfetchwith caching, database reads that are not per-user,generateMetadatafrom catalogue data. - Must be a hole:
cookies(),headers(),searchParams,draftMode(), uncachedfetch, 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.
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.