cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Say 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.
  • 02
    Read 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.
  • 03
    Know 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.

The Mint tea page: a cached price, and the stock for each visitNext.js
app/teas/mint/lib/
app/teas/mint/page.tsx
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>  );}
Recorded from Next.js 16.4.3

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
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time 300
x-nextjs-prerender 1
x-nextjs-postponed 1
X-Powered-By Next.js
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
Content-Type text/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 what Price returns and reuse it, instead of asking the supplier every time. Line 6 calls getPrice from lib/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 Stock inside Suspense, with the fallback Checking 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.

A browser window for the Mint tea page. Its heading, Mint tea, its description, and its price, 3 dollars a box, are plain cards labeled made by next build. Below them, a shaded card, Checking stock, marks the hole. An arrow leads from the hole to a card outside the window, In stock: 12 boxes, labeled rendered for each visit and streamed in./teas/mintMint teaCool and fresh…$3 a boxChecking stock…made by next build:the static shellIn stock: 12 boxesrendered for each visit,streamed into the hole
The Mint tea page in two kinds of parts. The heading, the description and the cached price are the static shell, prerendered by 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.
Real-life analogyPostcards printed ahead

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 build and kept
In real life: The empty lines, marked "Your message here"
In Next.js: A Suspense fallback in the shell, like Checking 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
The main idea

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 Suspense around 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 Suspense around 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:

Where each part of the Mint tea page went
Part of the pageWhere it goesWhat the recording shows
The heading and the description, plain JSXIn 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 StockIn the shell, in the hole's place.Checking stock… is in teas/mint.html.
Stock, an uncached fetch inside SuspenseA 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.

Playground: what goes into the static shell?
The Mint tea page, in this version of app/teas/mint/page.tsxNext.js
app/teas/mint/lib/
app/teas/mint/page.tsx
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>  );}
Recorded from Next.js 16.4.3
$ 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 kB
Try it yourself
The stock streamed

Stock 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.

Every version was built with next build from Next.js 16.4.3, with the same stand-in supplier, which answers the price and the stock after 400 ms each. The Terminal shows the route table and the files the build wrote; the other tabs show what next start sent for /teas/mint. Reset goes back to the first version.

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?

Playground: from next build to one visit
Who does whatThe Mint tea page
What the visitor seesafter step 1
localhost:3000/teas/mint

A blank page

React isn't running yet: clicks do nothing.

The page isn't complete yet.

Step 1 of 6
Next.js: next build renders the page

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….

Each step is one thing that happens, in order: first during next build, with no visitor yet, then during one visit. The screens are the recorded response for /teas/mint: its first part, then the page once the stock had arrived. Times aren't to scale. Reset goes back to the first step.

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.

Two halves. On the left, labeled next build: a terminal window writes two cards, teas/mint.html, the shell, and teas/mint.meta, the postponed state. On the right, labeled each visit: a window for one response holds the card teas/mint.html, sent first, then the card In stock: 12 boxes, rendered from where the build stopped. An arrow joins the two halves.next buildbuildteas/mint.htmlthe static shellteas/mint.metathe postponed stateeach visitresponseteas/mint.htmlsent first, as writtenIn stock: 12 boxesrendered for this visitone page, made at two times
Partial Prerendering in two moments. First, 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 Suspense sits to the uncached work, the more of the page is ready at once. A loading.tsx is 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 with x-nextjs-cache: HIT and Cache-Control: s-maxage=31536000: a kept copy, which shared caches may keep for a year.
  • /teas/mint came with x-nextjs-postponed: 1 and Cache-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:

Opening the home page, then clicking Mint teaNext.js
app/teas/mint/lib/
app/teas/mint/page.tsx
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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/Tea shop
Home Mint tea
Tea shop

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

  1. GET/200a whole page
  2. GET/200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /
  3. GET/teas/mint200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /
  4. GET/teas/mint200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /teas/mint/__PAGE__
    next-url
    /
  1. Open /. Line 10 of app/layout.tsx has a Link to /teas/mint. As soon as it's on the screen, the router prefetches the page, before any click: two requests for /teas/mint with the header next-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__).
  2. Click Mint tea. The address becomes /teas/mint, and one more request goes out, without the prefetch header. Its answer carries the stock, and In stock: 12 boxes appears.

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.

In the middle, a card for the static shell of the Mint tea page: Mint tea, the price and Checking stock. An arrow to the left leads to a window, first visit, holding teas/mint.html, HTML for the browser to show. An arrow to the right leads to a window, a link's prefetch, holding the page's segment, an RSC payload for the router. Neither holds the stock.the static shellMint tea · $3 a box · Checking stock…first visitteas/mint.htmlprefetch__PAGE__.segment.rscHTML, for the browser to showan RSC payload, for the router
One shell, two forms. A visitor who opens /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.
These file names are this build's

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:

Playground: one page, three next.config.ts files
The Mint tea shop, with this next.config.tsNext.js
app/teas/mint/lib/
next.config.tsmade by create-next-app
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  /* config options here */  cacheComponents: true,  partialPrefetching: true,}; export default nextConfig;
Recorded from Next.js 16.4.3
$ 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 kB
Try it yourself
cacheComponents: true

The 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.

Every version was built with next build from Next.js 16.4.3. Only next.config.ts changes, and the page's "use cache" line, which needs Cache Components. Reset goes back to the first version.

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:

Partial Prerendering, release by release
ReleaseDateWhat changed
Next.js 14October 26, 2023Partial Prerendering is announced as a preview.
Next.js 15 RCMay 23, 2024experimental.ppr: 'incremental' and export const experimental_ppr = true turn it on route by route.
Next.js 16.0October 21, 2025The flags are removed. cacheComponents: true makes it the default.
Next.js 16.3August 3, 2026partialPrefetching makes a link prefetch the route's shell.
Next.js 16.4October 6, 2026create-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:

app/teas/mint/page.tsx in a Next.js 15 canary, with the old route flagTSX
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:

  1. In next.config.ts, remove experimental.ppr, and set cacheComponents: true and partialPrefetching: true, as create-next-app does now.
  2. Remove every export const experimental_ppr. The codemod npx @next/codemod@latest remove-experimental-ppr . does it for you.
  3. Build, and fix what the build reports, like the uncached data outside Suspense from 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 Suspense right 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?

In the static shell, or a hole?
  • 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 Suspense around uncached work
  • Stock from an uncached fetch inside Suspense
  • An uncached fetch inside Suspense that answers at once
  • A component that reads cookies() inside Suspense
Try it yourself
0 of 6 correct

Sort each part: does next build put it in the static shell, or is it a hole, rendered for each request?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 Suspense always 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: true turns 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.

Exercise 1 · Warm-upRead the first part

A visitor opens /teas/mint. In the first part of the HTML, what shows where the stock will go?

Starter codeTSX
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>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict the symbol

    Which symbol does next build print for /teas/mint with this page?

    Starter codeTSX
    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>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWho asks the supplier during the build

      The supplier's stand-in answers /prices/mint and /stock/mint. Which of the two does it receive during next build?

      Starter codeTSX
      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>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFix the build

        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?

        Starter codeTSX
        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>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeAdd a second hole

          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?

          Starter codeTSX
          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>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeUpdate an old config

            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?

            Starter codeTypeScript
            import type { NextConfig } from "next";
            
            const nextConfig: NextConfig = {
              experimental: {
                ppr: true,
              },
            };
            
            export default nextConfig;
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what each part waits for, and whether that work is cached.

              The Partial Prerendering quiz · 7 questionsScore: first tries count
              1. Question 1 of 7The build prints ◐ /teas/mint. What does ◐ mean, in the legend's words?

                Choose an answer to see the explanation.

              2. Question 2 of 7Price and Stock each wait 400 ms for the supplier. Why is only the price in the static shell?

                Choose an answer to see the explanation.

              3. Question 3 of 7Which part of the Mint tea page is a hole?

                Read the code, then predictTSX
                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>
                  );
                }
                

                Choose an answer to see the explanation.

              4. Question 4 of 7Line 11 adds "use cache" to Stock. What does next build print for /teas/mint?

                Read the code, then predictTSX
                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>
                  );
                }
                

                Choose an answer to see the explanation.

              5. Question 5 of 7A link to /teas/mint comes on the screen in a new Next.js 16.4 app. What does its prefetch load?

                Choose an answer to see the explanation.

              6. Question 6 of 7This next.config.ts turned on Partial Prerendering in a Next.js 15 canary release. What does next build do with it in Next.js 16.4.3?

                Read the code, then predictTypeScript
                import type { NextConfig } from "next";
                
                const nextConfig: NextConfig = {
                  experimental: {
                    ppr: true,
                  },
                };
                
                export default nextConfig;
                

                Choose an answer to see the explanation.

              7. 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 Suspense around 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 with teas/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: true makes 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.

              Read it in Next.js's own code

              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.

              CompleteFrontend Clear concepts. Working examples.