Partial Prerendering
See one Next.js page made at two times: a static shell written by next build and sent at once, and dynamic holes behind Suspense streamed in for each visit.
- 01Say what's in the static shellTell which parts of a page next build puts in the shell (static content, cached content, fallbacks) and which are left as holes.
- 02Read Partial Prerendering in a responseFind the shell the build wrote, the parts streamed for each visit, and what a link's prefetch loads, in real recordings.
- 03Know where it came fromTell how the experimental flag of Next.js 14 and 15 became part of Cache Components in Next.js 16, and update an old config.
One page, made at two times
Streaming with Suspense showed a page arriving in pieces: a first part with fallbacks, then the parts that waited. That first part can be made long before anyone asks for the page. Here is the tea shop's Mint tea page again, with a price and a stock count from the tea supplier. It's a real Next.js 16.4.3 project, built with next build and run with next start. The supplier's API is a stand-in on the same computer, whose address is in SUPPLIER_API_URL; it answers the price and the stock after 400 ms each.
app/teas/mint/page.tsximport { Suspense } from "react";import { getPrice, getStock } from "@/lib/supplier"; async function Price() { "use cache"; const price = await getPrice("mint"); return <p>${price} a box</p>;} async function Stock() { const boxes = await getStock("mint"); return <p>In stock: {boxes} boxes</p>;} export default function MintTeaPage() { return ( <main> <h1>Mint tea</h1> <p>Cool and fresh, with a sweet finish.</p> <Price /> <Suspense fallback={<p>Checking stock…</p>}> <Stock /> </Suspense> </main> );}GET /teas/mint200
The first 1.5 kB of this response are .next/server/app/teas/mint.html, a file next build wrote, byte for byte (the Terminal tab lists it).
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 15 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/teas/mint">Mint tea</a></nav>
<!--&-->
<!--&-->
<!--&-->
<main>
<h1>Mint tea</h1>
<p>Cool and fresh, with a sweet finish.</p>
<p>$<!-- -->3<!-- --> a box</p>
<!--$?-->
<template id="B:0"></template>
<p>Checking stock…</p>
<!--/$-->
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script>…</script>
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
<div hidden="" id="S:0">
<p>In stock: <!-- -->12<!-- --> boxes</p>
</div>
<script>…</script>
<script>…</script>
</body>
</html>Here is app/teas/mint/page.tsx, line by line:
- Lines 4 to 8 are
Price. Line 5,"use cache", tells Next.js to keep whatPricereturns and reuse it, instead of asking the supplier every time. Line 6 callsgetPricefromlib/supplier.ts, which fetches the price. - Lines 10 to 13 are
Stock. It has no"use cache", so its fetch is uncached: the stock is read fresh every time the component runs. - Lines 18 and 19 are the heading and the description, written in the JSX. They wait for nothing.
- Lines 21 to 23 put
Stockinside Suspense, with the fallbackChecking stock….
Now read the Network tab. Above the headers, a note says the first 1.5 kB of the response are the file .next/server/app/teas/mint.html, byte for byte: a file that next build wrote. In the HTML below, that first part holds the heading, the description, $3 a box and Checking stock…. Further down, after the scripts, a hidden part holds In stock: 12 boxes, with a script that moves it into the fallback's place. Then open the Terminal tab: the build printed ◐ /teas/mint, and its legend says ◐ means (Partial Prerender) prerendered as static HTML with dynamic server-streamed content. Under the build's output, the list of files it wrote has teas/mint.html in it.
So this page was made at two times. Most of it was prerendered and cached, first during the build. The stock was made for this visit, and streamed into the same response after the cached part. The cached part isn't made only once: the route table's 15m under Revalidate and 1y under Expire, next to ◐ /teas/mint, come from the cached price's lifetime, and Next.js can make the shell again later, as that lifetime says. The Cache lifetimes and tags lesson, in the Rendering and caching stage, teaches those columns. That's Partial Prerendering, often shortened to PPR. Prerendering means rendering ahead of time, before any request. The part made ahead is the static shell. A part left for each request is a dynamic hole: a Suspense boundary whose content waits for something that can't be prerendered, here the uncached stock.
next build and kept, with Checking stock… holding the hole's place. The stock is rendered for each visit and streamed into that place, in the same response.A shop prints its holiday postcards in the autumn: the picture and the greeting are the same on every card, so they're printed by the thousand. Each card keeps a few empty lines. When you buy one, you write your own message there, for one friend, and post the whole card in one envelope.
- In real life: The picture and the greeting, printed on a thousand cards before the holidays
- In Next.js: The static shell, prerendered by
next buildand kept - In real life: The empty lines, marked "Your message here"
- In Next.js: A
Suspensefallback in the shell, likeChecking stock… - In real life: The message you write on one card, for one friend
- In Next.js: A hole's content, rendered for one request
- In real life: One card in one envelope
- In Next.js: One HTML response: the shell first, then the hole's content
Partial Prerendering makes one page at two times. Next.js prerenders everything it can into a static shell, and caches it: first during the build, and again later as its cache lifetime says. The shell holds the static content, the cached content, and a fallback for each Suspense whose content waits for uncached or request-time work. For a request, it sends that shell at once, renders only the holes, and streams them into the same response. With Cache Components on, as in new projects, each route ends up one of three ways: ○ (Static) prerendered as static content, ◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content, or ƒ (Dynamic) server-rendered on demand, for a route allowed to block.
What goes into the static shell
The docs list what a route's static shell holds. Three kinds of things go in, and the Mint tea page has one of each:
- Static content: anything that reads nothing from the request and waits for nothing uncached, like the heading. Plain synchronous work finishes during the build too, with or without a
Suspensearound it. - Cached content: the result of
"use cache", like the price. The build waits for it, keeps it, and puts it in the shell, as long as its cache lifetime isn't too short. Lifetimes are taught in the Rendering and caching stage's caching lessons. - Fallbacks: for each
Suspensearound uncached or request-time work, its fallback goes in the shell, in the place its content will take.
Everything else is a hole. The recording kept every request the supplier's stand-in received, and when. During next build it was asked for /prices/mint, once, and never for the stock. During the visits, it was asked for /stock/mint each time the page was rendered, and never for the price: the cached price came from the shell. Each row is one part of the page, where the build put it, and what the recording shows:
| Part of the page | Where it goes | What the recording shows |
|---|---|---|
| The heading and the description, plain JSX | In the shell. | Mint tea is in teas/mint.html. |
Price, marked "use cache" | In the shell. The build waited for it and kept the result. | $3 a box is in teas/mint.html; the supplier was asked once, during the build. |
The Suspense fallback above Stock | In the shell, in the hole's place. | Checking stock… is in teas/mint.html. |
Stock, an uncached fetch inside Suspense | A hole, rendered for each request. | In stock is nowhere in teas/mint.html; the supplier was asked once per visit. |
Notice what didn't decide. The price and the stock both wait 400 ms for the supplier, and one is in the shell while the other is a hole. Next.js doesn't look at how long a part takes. With Cache Components on, it looks at what the part waits for: uncached work that suspends (makes the component wait) is left for the request, and cached work is done ahead, first during the build, however long it takes. Each button below is one version of the page, built with real Next.js. Before you click, guess what each build prints for /teas/mint.
app/teas/mint/page.tsxNext.jsapp/teas/mint/page.tsximport { Suspense } from "react";import { getPrice, getStock } from "@/lib/supplier"; async function Price() { "use cache"; const price = await getPrice("mint"); return <p>${price} a box</p>;} async function Stock() { const boxes = await getStock("mint"); return <p>In stock: {boxes} boxes</p>;} export default function MintTeaPage() { return ( <main> <h1>Mint tea</h1> <p>Cool and fresh, with a sweet finish.</p> <Price /> <Suspense fallback={<p>Checking stock…</p>}> <Stock /> </Suspense> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (4/4) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /├ ○ /_not-found└ ◐ /teas/mint 15m 1y ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html 9.2 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.7 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.6 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.6 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 4.1 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/index.html 5.3 kB.next/server/app/index.meta 617 bytes.next/server/app/index.rsc 3.1 kB.next/server/app/index.segments/__PAGE__.segment.rsc 2.8 kB.next/server/app/index.segments/_full.segment.rsc 3.1 kB.next/server/app/index.segments/_tree.segment.rsc 115 bytes.next/server/app/teas/mint.html 1.5 kB.next/server/app/teas/mint.meta 4.1 kB.next/server/app/teas/mint.segments/_full.segment.rsc 4.1 kB.next/server/app/teas/mint.segments/_tree.segment.rsc 215 bytes.next/server/app/teas/mint.segments/teas/mint/__PAGE__.segment.rsc 3.4 kBStock on lines 10 to 13 isn't cached, so its fetch is left for each request, and the Suspense on lines 21 to 23 puts its fallback in the shell. The build prints ◐ /teas/mint, and writes teas/mint.html and teas/mint.meta.
In The stock cached too, line 11 of the page caches Stock. Nothing is left for the request, so the shell is the whole page, and the build prints ○ /teas/mint: (Static) prerendered as static content. In this build, the stand-in was asked for the stock during the build, and teas/mint.html holds In stock: 12 boxes. Every visitor gets that count until the cached result is made again. In The stock with no Suspense, line 21 renders <Stock /> with nothing to show while it waits. The build can't make a shell for the page, so it stops with Next.js encountered uncached or runtime data during prerendering, and lists three fixes: [stream], a Suspense around it; [cache], "use cache"; and [block], export const instant = false, which allows a blocking route: one that waits before it sends the page.
Uncached work isn't only fetch. The error names the others: a database query, cookies(), headers(), connection(), and params or searchParams that aren't known during the build. Request-time APIs covers the request-time ones. Dynamic routes showed what params does to the shell.
Dynamic holes behind Suspense
A hole is a Suspense boundary whose content can't be prerendered. During the build, Next.js renders the rest of the page and leaves that content unfinished: the fallback goes in the shell, and the content waits for a request. Next.js also saves where it stopped. The docs call this the postponed state, and use it to resume, or continue, the render later. In this build, .next/server/app/teas/mint.meta, the file next to the shell, holds the postponed state. In the version with the stock cached, teas/mint.meta holds none: there was nothing left to finish.
Step through one build and one visit. Before you start, guess: at which step does the visitor first see the price?
The Mint tea pageafter step 1A blank page
React isn't running yet: clicks do nothing.
The page isn't complete yet.
There's no visitor yet. Next.js renders MintTeaPage: the heading and the description, then Price, which asks the supplier once and is cached. Stock's fetch isn't cached, so Next.js doesn't wait for it: the Suspense above it gives the fallback, Checking stock….
The price shows at step 4, with the rest of the shell, before anything is rendered for the visit. Step 5 is the only work done for this visit: Stock runs, and the supplier is asked for the stock. Step 6 streams it into the hole. That's why the stand-in never got a request for the price after the build.
next build writes the shell to teas/mint.html, and keeps postponed state in teas/mint.meta. For each visit, the response starts with that file, and Next.js finishes only the hole, from where the build stopped. Next.js can make the shell again later, as its cache lifetime says. The file names are this build's.Some things are true of every hole:
- Each hole is its own boundary. A page can have many. Add the supplier's reviews in a second
Suspense, and the shell holds two fallbacks; each hole streams in when its own content is ready, as Streaming with Suspense recorded. - A hole only holds what's inside its boundary. Everything outside it is in the shell. So the closer the
Suspensesits to the uncached work, the more of the page is ready at once. Aloading.tsxis a boundary around the whole page (Loading UI): its shell has the layout and one fallback. - Suspense alone doesn't make a hole. The docs say so: a component that only does synchronous work finishes during the build, with or without a boundary. A hole needs work that waits and isn't cached.
Partial Prerendering in the Network tab
Go back to the first example's Network tab, and compare /teas/mint with /, the home page, which has no hole. Its note says the whole response is .next/server/app/index.html, byte for byte: a page prerendered as static content (○ /) is sent exactly as the build wrote it. /teas/mint's response starts with its file, and goes on with what was rendered for the visit. The headers differ too. In this build:
/came withx-nextjs-cache: HITandCache-Control: s-maxage=31536000: a kept copy, which shared caches may keep for a year./teas/mintcame withx-nextjs-postponed: 1andCache-Control: private, no-cache, no-store…: the response holds the stock rendered for this visit, so nobody may keep a copy of it.
Headers whose names start with x-nextjs- are Next.js's own. They're what this build sent, not a documented API: read them to understand a response, and don't write code that depends on them. To see the same thing in your own app, run next build and next start, open a page in Chrome, and open DevTools' Network panel: pick the page's request, and its Response tab shows the HTML in the order it arrived.
What a link prefetches
The shell has a second form. The docs say the build makes it as HTML, for a visitor who opens the page, and as an RSC payload, the data React uses on a client-side navigation. A Link uses the second form. These steps were taken in real Chrome, against the same running app. Open the Steps tab:
app/teas/mint/page.tsximport { Suspense } from "react";import { getPrice, getStock } from "@/lib/supplier"; async function Price() { "use cache"; const price = await getPrice("mint"); return <p>${price} a box</p>;} async function Stock() { const boxes = await getStock("mint"); return <p>In stock: {boxes} boxes</p>;} export default function MintTeaPage() { return ( <main> <h1>Mint tea</h1> <p>Cool and fresh, with a sweet finish.</p> <Price /> <Suspense fallback={<p>Checking stock…</p>}> <Stock /> </Suspense> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Fresh tea, packed by hand.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (4), without the files it loaded
GET/200a whole pageGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas/mint200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas/mint200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/mint/__PAGE__
- next-url
- /
- Open /. Line 10 of
app/layout.tsxhas aLinkto/teas/mint. As soon as it's on the screen, the router prefetches the page, before any click: two requests for/teas/mintwith the headernext-router-prefetch: 1. One asks for the route's tree (next-router-segment-prefetch: /_tree), and one for the page's part (/teas/mint/__PAGE__). - Click Mint tea. The address becomes
/teas/mint, and one more request goes out, without the prefetch header. Its answer carries the stock, andIn stock: 12 boxesappears.
The recorder also sent the page's prefetch on its own, with the headers Chrome sent. Pick the prefetch of the page in the Network tab: its answer, an RSC payload, holds the heading, the price and Checking stock…, and no stock, and the supplier wasn't asked for it. With partial prefetching on, as in new projects, a link prefetches the route's static shell (the docs call this per-route prefetch the App Shell), not the whole page. The build had written that part ahead too: teas/mint.segments/teas/mint/__PAGE__.segment.rsc in the Terminal's list holds the fallback, and no stock. Linking and navigating showed prefetching from the link's side, and Instant navigations and partial prefetching shows how to choose what a prefetch includes.
/teas/mint gets it as HTML, teas/mint.html. A link to it gets it as an RSC payload, which the router prefetched from the page's segment file. Neither holds the stock: it comes with the visit or the click.teas/mint.html, teas/mint.meta, the segment files and the x-nextjs- headers are how Next.js 16.4.3 stores and sends Partial Prerendering when it runs with next start. They help you see what happened. They aren't a promise: another version, or a hosting platform with its own way to serve the shell, can store them differently. What stays true is the model: a shell made ahead and cached, holes rendered for the request, one response.
From experimental.ppr to Cache Components
Before Partial Prerendering, Next.js decided per route: a whole page was made during the build, or a whole page was made for each request. The docs' Rendering Philosophy guide calls that a route-level boundary, and Partial Prerendering a component-level one. You can still see the route-level way today: Next.js 16 without Cache Components works like that. Each button is the same shop with another next.config.ts, built with Next.js 16.4.3:
next.config.tsNext.jsnext.config.tsmade by create-next-appimport type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true,}; export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (4/4) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /├ ○ /_not-found└ ◐ /teas/mint 15m 1y ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html 9.2 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.7 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.6 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.6 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 4.1 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/index.html 5.3 kB.next/server/app/index.meta 617 bytes.next/server/app/index.rsc 3.1 kB.next/server/app/index.segments/__PAGE__.segment.rsc 2.8 kB.next/server/app/index.segments/_full.segment.rsc 3.1 kB.next/server/app/index.segments/_tree.segment.rsc 115 bytes.next/server/app/teas/mint.html 1.5 kB.next/server/app/teas/mint.meta 4.1 kB.next/server/app/teas/mint.segments/_full.segment.rsc 4.1 kB.next/server/app/teas/mint.segments/_tree.segment.rsc 215 bytes.next/server/app/teas/mint.segments/teas/mint/__PAGE__.segment.rsc 3.4 kBThe file create-next-app writes. Line 5 turns on Cache Components, and with it Partial Prerendering as the default: ◐ /teas/mint, a shell with the price, and the stock streamed for each visit.
With No option, the build printed ○ /teas/mint, and the stand-in was asked for the stock during the build: the whole page, stock included, was made once. In that model, a fetch with no cache option didn't keep a page from being made during the build, as this recording shows. Reading the request, or a fetch marked cache: 'no-store', made the whole route render for each request instead. Caching before Cache Components teaches that older caching model. With experimental.ppr, the build stopped at once: `experimental.ppr` has been merged into `cacheComponents`.
The release posts tell how it got there. Each row is one release:
| Release | Date | What changed |
|---|---|---|
| Next.js 14 | October 26, 2023 | Partial Prerendering is announced as a preview. |
| Next.js 15 RC | May 23, 2024 | experimental.ppr: 'incremental' and export const experimental_ppr = true turn it on route by route. |
| Next.js 16.0 | October 21, 2025 | The flags are removed. cacheComponents: true makes it the default. |
| Next.js 16.3 | August 3, 2026 | partialPrefetching makes a link prefetch the route's shell. |
| Next.js 16.4 | October 6, 2026 | create-next-app turns on both options in every new app. |
In the Next.js 15 canary releases, you turned it on route by route. The config set ppr: 'incremental', and each page or layout that wanted it exported a flag:
import { Suspense } from "react";
import { getPrice, getStock } from "@/lib/supplier";
export const experimental_ppr = true;
// …the rest of the page
Line 4 opted this route in. Next.js 16 removed both the experimental.ppr option and the experimental_ppr export. The upgrade guide also warns that Partial Prerendering in Next.js 16 works differently than in the Next.js 15 canaries. To move an app over:
- In
next.config.ts, removeexperimental.ppr, and setcacheComponents: trueandpartialPrefetching: true, ascreate-next-appdoes now. - Remove every
export const experimental_ppr. The codemodnpx @next/codemod@latest remove-experimental-ppr .does it for you. - Build, and fix what the build reports, like the uncached data outside
Suspensefrom the playground above.
The docs say both options will be on everywhere in the next major release, and the options themselves removed. Until then, a new app has them in its next.config.ts.
Planning a page's shell
To plan a page's shell, ask three questions of each part:
- Does it wait for anything that isn't cached, or read the request? If not, it's in the shell already.
- Should every visitor see the same value for a while? Then cache it with
"use cache", and it joins the shell, like the price. - Must it be fresh for each request, or belong to one visitor? Then it's a hole: put a
Suspenseright above it, with a fallback the shape of what will come, like the stock.
Sort these parts of a page: are they in the static shell, or holes made for each request?
- The page's heading, written in the JSX
- A price from a
"use cache"component that waits 400 ms for the supplier - The fallback of a
Suspensearound uncached work - Stock from an uncached fetch inside
Suspense - An uncached fetch inside
Suspensethat answers at once - A component that reads
cookies()insideSuspense
Sort each part: does next build put it in the static shell, or is it a hole, rendered for each request?
Common mix-ups
- "Slow parts become holes." No. Uncached work that waits becomes a hole. The cached price waits as long as the stock, and it's in the shell.
- "A
Suspensealways makes a hole." No. Synchronous work, and cached work, finish during the build, even inside a boundary. - "A
◐page is rendered from scratch for each request." No. The response starts with the file the build wrote; only the holes run for the visit. The price was never asked for again. - "The hole is filled by the browser, after the page loads." No. The server renders it and streams it in the same HTML response. The browser only moves it into place.
- "A link prefetches the whole page." No. With partial prefetching on, it prefetched the shell, with
Checking stock…. The stock came after the click. - "You turn on Partial Prerendering with
experimental.ppr." Not since Next.js 16. The build stops;cacheComponents: trueturns it on.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints. They use the same stand-in supplier: every answer waits 400 ms.
A visitor opens /teas/mint. In the first part of the HTML, what shows where the stock will go?
import { Suspense } from "react";
import { getPrice, getStock } from "@/lib/supplier";
async function Price() {
"use cache";
const price = await getPrice("mint");
return <p>${price} a box</p>;
}
async function Stock() {
const boxes = await getStock("mint");
return <p>In stock: {boxes} boxes</p>;
}
export default function MintTeaPage() {
return (
<main>
<h1>Mint tea</h1>
<p>Cool and fresh, with a sweet finish.</p>
<Price />
<Suspense fallback={<p>Checking stock…</p>}>
<Stock />
</Suspense>
</main>
);
}
Checking stock…, the fallback on line 21. Stock isn't cached, so the shell holds its fallback, and In stock: 12 boxes arrives later in the same response.
Which symbol does next build print for /teas/mint with this page?
import { Suspense } from "react";
import { getPrice, getStock } from "@/lib/supplier";
async function Price() {
"use cache";
const price = await getPrice("mint");
return <p>${price} a box</p>;
}
async function Stock() {
"use cache";
const boxes = await getStock("mint");
return <p>In stock: {boxes} boxes</p>;
}
export default function MintTeaPage() {
return (
<main>
<h1>Mint tea</h1>
<p>Cool and fresh, with a sweet finish.</p>
<Price />
<Suspense fallback={<p>Checking stock…</p>}>
<Stock />
</Suspense>
</main>
);
}
○. Both components are cached, so the shell is the whole page: the recorded build printed ○ /teas/mint, prerendered as static content.
The supplier's stand-in answers /prices/mint and /stock/mint. Which of the two does it receive during next build?
import { Suspense } from "react";
import { getPrice, getStock } from "@/lib/supplier";
async function Price() {
"use cache";
const price = await getPrice("mint");
return <p>${price} a box</p>;
}
async function Stock() {
const boxes = await getStock("mint");
return <p>In stock: {boxes} boxes</p>;
}
export default function MintTeaPage() {
return (
<main>
<h1>Mint tea</h1>
<p>Cool and fresh, with a sweet finish.</p>
<Price />
<Suspense fallback={<p>Checking stock…</p>}>
<Stock />
</Suspense>
</main>
);
}
Only /prices/mint. Price is cached, so the build asked for the price once. Stock isn't, so the stand-in got /stock/mint only during the visits.
This page stops the build with Next.js encountered uncached or runtime data during prerendering. Fix it so the stock streams in. Which symbol does the build print then?
import { Suspense } from "react";
import { getPrice, getStock } from "@/lib/supplier";
async function Price() {
"use cache";
const price = await getPrice("mint");
return <p>${price} a box</p>;
}
async function Stock() {
const boxes = await getStock("mint");
return <p>In stock: {boxes} boxes</p>;
}
export default function MintTeaPage() {
return (
<main>
<h1>Mint tea</h1>
<p>Cool and fresh, with a sweet finish.</p>
<Price />
<Stock />
</main>
);
}
import { Suspense } from "react";
import { getPrice, getStock } from "@/lib/supplier";
async function Price() {
"use cache";
const price = await getPrice("mint");
return <p>${price} a box</p>;
}
async function Stock() {
const boxes = await getStock("mint");
return <p>In stock: {boxes} boxes</p>;
}
export default function MintTeaPage() {
return (
<main>
<h1>Mint tea</h1>
<p>Cool and fresh, with a sweet finish.</p>
<Price />
<Suspense fallback={<p>Checking stock…</p>}>
<Stock />
</Suspense>
</main>
);
}
◐. With <Stock /> inside <Suspense fallback={<p>Checking stock…</p>}>, the build has a fallback for the hole, makes the shell, and prints ◐ /teas/mint.
lib/supplier.ts gains getReviews("mint"), an uncached fetch of the tea's reviews. Show them under the stock, without making the visitor wait for them to see the rest. How many fallbacks does the static shell hold?
import { Suspense } from "react";
import { getPrice, getStock } from "@/lib/supplier";
async function Price() {
"use cache";
const price = await getPrice("mint");
return <p>${price} a box</p>;
}
async function Stock() {
const boxes = await getStock("mint");
return <p>In stock: {boxes} boxes</p>;
}
export default function MintTeaPage() {
return (
<main>
<h1>Mint tea</h1>
<p>Cool and fresh, with a sweet finish.</p>
<Price />
<Suspense fallback={<p>Checking stock…</p>}>
<Stock />
</Suspense>
{/* The reviews go here: getReviews("mint") from lib/supplier.ts */}
</main>
);
}
import { Suspense } from "react";
import { getPrice, getReviews, getStock } from "@/lib/supplier";
async function Price() {
"use cache";
const price = await getPrice("mint");
return <p>${price} a box</p>;
}
async function Stock() {
const boxes = await getStock("mint");
return <p>In stock: {boxes} boxes</p>;
}
async function Reviews() {
const reviews = await getReviews("mint");
return (
<ul>
{reviews.map((review) => (
<li key={review}>{review}</li>
))}
</ul>
);
}
export default function MintTeaPage() {
return (
<main>
<h1>Mint tea</h1>
<p>Cool and fresh, with a sweet finish.</p>
<Price />
<Suspense fallback={<p>Checking stock…</p>}>
<Stock />
</Suspense>
<Suspense fallback={<p>Loading reviews…</p>}>
<Reviews />
</Suspense>
</main>
);
}
2. The recorded build printed ◐ /teas/mint, and the first part held both Checking stock… and Loading reviews…. The heading and the price are still in the shell.
An app that tried Partial Prerendering on a Next.js 15 canary has this next.config.ts. With Next.js 16.4.3, its build stops. Which option goes in place of experimental.ppr?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
};
export default nextConfig;
cacheComponents: true, with partialPrefetching: true next to it, as create-next-app writes them. The recorded build with experimental.ppr stopped: experimental.ppr has been merged into cacheComponents.
Check your understanding
For each question, ask what each part waits for, and whether that work is cached.
Question 1 of 7The build prints
◐ /teas/mint. What does◐mean, in the legend's words?Choose an answer to see the explanation.
Question 2 of 7
PriceandStockeach wait 400 ms for the supplier. Why is only the price in the static shell?Choose an answer to see the explanation.
Question 3 of 7Which part of the Mint tea page is a hole?
Read the code, then predictTSXimport { Suspense } from "react"; import { getPrice, getStock } from "@/lib/supplier"; async function Price() { "use cache"; const price = await getPrice("mint"); return <p>${price} a box</p>; } async function Stock() { const boxes = await getStock("mint"); return <p>In stock: {boxes} boxes</p>; } export default function MintTeaPage() { return ( <main> <h1>Mint tea</h1> <p>Cool and fresh, with a sweet finish.</p> <Price /> <Suspense fallback={<p>Checking stock…</p>}> <Stock /> </Suspense> </main> ); }Choose an answer to see the explanation.
Question 4 of 7Line 11 adds
"use cache"toStock. What doesnext buildprint for/teas/mint?Read the code, then predictTSXimport { Suspense } from "react"; import { getPrice, getStock } from "@/lib/supplier"; async function Price() { "use cache"; const price = await getPrice("mint"); return <p>${price} a box</p>; } async function Stock() { "use cache"; const boxes = await getStock("mint"); return <p>In stock: {boxes} boxes</p>; } export default function MintTeaPage() { return ( <main> <h1>Mint tea</h1> <p>Cool and fresh, with a sweet finish.</p> <Price /> <Suspense fallback={<p>Checking stock…</p>}> <Stock /> </Suspense> </main> ); }Choose an answer to see the explanation.
Question 5 of 7A link to
/teas/mintcomes on the screen in a new Next.js 16.4 app. What does its prefetch load?Choose an answer to see the explanation.
Question 6 of 7This
next.config.tsturned on Partial Prerendering in a Next.js 15 canary release. What doesnext builddo with it in Next.js 16.4.3?Read the code, then predictTypeScriptimport type { NextConfig } from "next"; const nextConfig: NextConfig = { experimental: { ppr: true, }, }; export default nextConfig;Choose an answer to see the explanation.
Question 7 of 7Where did the first part of the Mint tea page's response come from?
Choose an answer to see the explanation.
Key takeaways
- Partial Prerendering makes one page at two times: a static shell, prerendered and cached (first during the build, then again as its cache lifetime says), and the holes, rendered for the request and streamed into the same response.
- The shell holds static content, cached content, and the fallback of each
Suspensearound uncached or request-time work. Uncached work that suspends becomes a hole; speed doesn't decide. ◐means prerendered as static HTML with dynamic server-streamed content. In this build, the response began withteas/mint.html, byte for byte, and only the uncached stock was rendered, for every visit. A hole isn't always rendered on every request: generateStaticParams shows a shell for an unlisted param whose page is made on the first visit, then served whole.- A link prefetches the route's shell, as an RSC payload:
Checking stock…, not the stock. experimental.ppr, the flag of the experimental releases before Next.js 16, is gone.cacheComponents: truemakes Partial Prerendering the default, and new apps have it. Each route then ends up○,◐orƒ.
Remember the one-liner.
The shell is prerendered and kept; a request renders only the holes.
These links point at release v16.4.3. The build error for the old flag is thrown in lines 674 to 678 of server/config.ts. The name of the x-nextjs-postponed header is a constant in line 35 of app-router-headers.ts. Both are details of this version. In the course's last stage, Prerendering and Partial Prerendering reads how the render is resumed.
Coming next: generateStaticParams. It lists a dynamic route's params at build time, so each listed page gets a shell of its own, and shows what happens to the rest.