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

Streaming with Suspense

See a Next.js page's HTML arrive in pieces, put each Suspense above the part that waits, stream promises to Client Components, and keep pages readable for bots.

By the end, you can
  • 01
    Read a streamed responseFind the static shell, the fallbacks and the parts that arrive later in a page's HTML, and say what decides their order.
  • 02
    Place boundaries wellPut each Suspense above the component that waits, and choose between loading.tsx and a Suspense in the page.
  • 03
    Stream for people and for botsPass a promise to a Client Component, and know what a streamed page sends search engines and link previews.

A tea page that arrives in pieces

The tea shop's Mint tea page shows a description that never changes, how many bags are left, and the reviews. The stock and the reviews come from the shop's database, read for each visitor, and each read takes a moment. The visitor shouldn't stare at a blank page until the slowest one is done. Here is the page, as a real Next.js 16.4.3 project: it was built with next build, run with next start, and asked for /teas/mint, and what came back was recorded.

The Mint tea page, with a Suspense above each part that waitsNext.js
app/teas/mint/lib/
app/teas/mint/page.tsx
import { Suspense } from "react";import { getReviews, getStock } from "@/lib/shop"; async function Stock() {  const stock = await getStock("mint");  return <p>{stock} bags left</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>Fresh mint leaves, picked in May.</p>      <Suspense fallback={<p>Checking stock…</p>}>        <Stock />      </Suspense>      <h2>Reviews</h2>      <Suspense fallback={<p>Loading reviews…</p>}>        <Reviews />      </Suspense>    </main>  );}
Recorded from Next.js 16.4.3

GET /teas/mint200

Sent with the request
user-agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
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 16 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/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <!--&-->
    <main>
      <h1>Mint tea</h1>
      <p>Fresh mint leaves, picked in May.</p>
      <!--$?-->
      <template id="B:0"></template>
      <p>Checking stock…</p>
      <!--/$-->
      <h2>Reviews</h2>
      <!--$?-->
      <template id="B:1"></template>
      <p>Loading reviews…</p>
      <!--/$-->
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <!--/&-->
    <script>…</script>
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <div hidden="" id="S:0">
      <p>4<!-- --> bags left</p>
    </div>
    <script>…</script>
    <div hidden="" id="S:1">
      <ul>
        <li>Cool and fresh.</li>
        <li>Lovely after dinner.</li>
      </ul>
    </div>
    <script>…</script>
    <script>…</script>
  </body>
</html>

app/teas/mint/page.tsx has two async components. Stock, on lines 4 to 7, waits for getStock and says how many bags are left. Reviews, on lines 9 to 18, waits for getReviews and lists them. The page puts each one inside its own Suspense: lines 25 to 27, with the fallback Checking stock…, and lines 29 to 31, with Loading reviews…. Now click lib/shop.ts, the stand-in database. Lines 10 and 16 call connection(), so the reads happen for each request and never during the build. Lines 11 and 17 wait on purpose, the stock a little and the reviews longer, as the comment on lines 7 and 8 says.

The Network tab shows the HTML in the order it arrived. Near the top, inside <main>, are the heading, the description, Checking stock… and Loading reviews…. That first part is the static shell: the static content and anything cached, made during the build, plus a fallback for each Suspense whose content waits for uncached or request-time work, like these two reads. Further down, after the scripts, come two hidden parts: a <div hidden> with 4 bags left, and then another with the two reviews. Each is followed by a small script that React adds, which moves the part into its fallback's place. The Browser tab shows the page after both moves.

Sending one response in pieces, and showing each piece as soon as it arrives, is streaming. Loading UI showed it with one fallback for a whole page. Here the page has two, and each part arrives on its own:

Three windows in a row, joined by arrows, labeled one response, in the order it arrived. The first, the shell, holds Mint tea, the description, Checking stock and Loading reviews. The second, the stock, holds 4 bags left. The third, the reviews, holds Cool and fresh and Lovely after dinner.shellMint teaFresh mint leaves…Checking stock…Loading reviews…stock4 bags leftreviewsCool and fresh.Lovely after dinner.one response, in the order it arrived
One response for /teas/mint, in the order it arrived. First the static shell, made during the build, with both fallbacks. Then the stock, whose read ended first, and last the reviews. Each later part takes its fallback's place.
Real-life analogyOne order, several parcels

You order a lamp and a rug from one shop. The shop doesn't hold the lamp back until the rug is packed: each parcel leaves as soon as it's ready, labeled with the room it goes in. Meanwhile your room is already set up, with a note on the floor where the rug will lie.

In real life: The room you set up at once, with a note where each new thing will go
In Next.js: The static shell, with a fallback for each part that waits for request-time data
In real life: Each parcel, sent as soon as it's packed
In Next.js: Each streamed part, sent when its data is ready
In real life: The room written on each parcel's label
In Next.js: The small script that puts each part in its fallback's place
In real life: One order, delivered in several parcels
In Next.js: One HTML response, sent in several parts
The main idea

Streaming sends a page as one response, in pieces. The static shell goes first: the static and cached content, and a fallback for each Suspense whose content waits for uncached or request-time work. Each of those boundaries' content follows as soon as everything inside it is ready. A boundary decides what can wait; everything outside it shows at once.

How the pieces arrive

Open the Mint tea page's Terminal tab: ◐ /teas/mint, prerendered as static HTML with dynamic server-streamed content. The shell was made during the build. For each visit, Next.js sends it at once, starts the components that wait, and keeps the response open. Step through one visit:

Playground: one visit to the Mint tea page
Who does whatVisit /teas/mint
What the visitor seesafter step 1
tea-shop.example/teas/mint

A blank page

React isn't running yet: clicks do nothing.

The page isn't complete yet.

Step 1 of 5
Browser: The visitor opens the Mint tea page

The browser asks the server for /teas/mint.

Each step is one thing that happens, in order, for one visit. The screens come from the recording of the Mint tea page: its first part, the order its streamed parts arrived in, and the page once both had arrived. Times aren't to scale. Reset goes back to the first step.

The status code and the headers leave first, with the shell. Then, as each boundary's content is ready, React renders it to HTML and sends it, with its script. The same response also carries, in other inline scripts, the RSC payload of each part: the description of what the Server Components rendered, which React reads when the page's JavaScript takes over in the browser (How Next.js works introduced it). On a client-side navigation, a click on a link to a page under the same root layout, there's no HTML at all: the router fetches only the RSC payload, and it streams the same way. A link to a page under another root layout loads a whole new document.

Does the stock come first because it's higher on the page? Each version of the page changes one thing. Before you click, guess which hidden part comes first in the Network tab:

Playground: which part arrives first?
The Mint tea page, in this versionNext.js
app/teas/mint/lib/
lib/shop.ts
import { connection } from "next/server"; function wait(ms: number) {  return new Promise((resolve) => setTimeout(resolve, ms));} // A stand-in for the shop's database, read for each request. Each read waits// on purpose: the stock 200 ms, the reviews 600 ms.export async function getStock(id: string) {  await connection();  await wait(200);  return id === "mint" ? 4 : 0;} export async function getReviews(id: string) {  await connection();  await wait(600);  return id === "mint" ? ["Cool and fresh.", "Lovely after dinner."] : [];}
Recorded from Next.js 16.4.3

GET /teas/mint200

Sent with the request
user-agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
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 16 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/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <!--&-->
    <main>
      <h1>Mint tea</h1>
      <p>Fresh mint leaves, picked in May.</p>
      <!--$?-->
      <template id="B:0"></template>
      <p>Checking stock…</p>
      <!--/$-->
      <h2>Reviews</h2>
      <!--$?-->
      <template id="B:1"></template>
      <p>Loading reviews…</p>
      <!--/$-->
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <!--/&-->
    <script>…</script>
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <div hidden="" id="S:0">
      <p>4<!-- --> bags left</p>
    </div>
    <script>…</script>
    <div hidden="" id="S:1">
      <ul>
        <li>Cool and fresh.</li>
        <li>Lovely after dinner.</li>
      </ul>
    </div>
    <script>…</script>
    <script>…</script>
  </body>
</html>
Try it yourself
Stock ready first

The stock waits less than the reviews. In the HTML, the stock's hidden part comes before the reviews' one.

Each version was built with next build from Next.js 16.4.3, run with next start, and asked for /teas/mint. The Network tab shows the HTML in the order it arrived. Reset goes back to the first version.

In Reviews ready first, only lines 11 and 17 of lib/shop.ts changed: the waits are swapped. Now the hidden part with the reviews arrives before the one with the stock, although the reviews sit lower on the page. A boundary is sent when its content is ready, not in page order, and one slow part never holds back another.

In One boundary for both, lines 25 to 29 of the page wrap both parts in a single Suspense. The first HTML has one fallback, Loading stock and reviews…. The parts still arrive one by one, but React shows a boundary's content only once everything inside it is ready, so the visitor sees the stock and the reviews together, after the slower read. That's useful when parts belong together and shouldn't pop in one at a time. React's Streaming lesson shows where these pieces come from in React itself.

loading.tsx or Suspense?

Loading UI showed that loading.tsx is a Suspense around the whole page. It streams the same way. The difference is what it puts behind the fallback. In this version, the page awaits both reads itself, with Promise.all on line 4 of app/teas/mint/page.tsx, and app/teas/mint/loading.tsx gives the whole page one loading state:

The Mint tea page with loading.tsxNext.js
app/teas/mint/lib/
app/teas/mint/loading.tsx
export default function Loading() {  return <p>Loading the tea page…</p>;}
Recorded from Next.js 16.4.3

GET /teas/mint200

Sent with the request
user-agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
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/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <!--&-->
    <!--$?-->
    <template id="B:0"></template>
    <p>Loading the tea page…</p>
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <!--/&-->
    <script>…</script>
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <div hidden="" id="S:0">
      <template id="P:1"></template>
      <!--$-->
      <!--/$-->
    </div>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <div hidden="" id="S:1">
      <main>
        <h1>Mint tea</h1>
        <p>Fresh mint leaves, picked in May.</p>
        <p>4<!-- --> bags left</p>
        <h2>Reviews</h2>
        <ul>
          <li>Cool and fresh.</li>
          <li>Lovely after dinner.</li>
        </ul>
      </main>
    </div>
    <script>…</script>
    <script>…</script>
  </body>
</html>

The first part of the HTML holds Loading the tea page… and nothing else of the page: not the heading, not the description, though neither of them waits for anything. They're inside the page, and the page waits. Everything arrives later, in a hidden part further down. Each row compares the two ways on this page:

loading.tsx and Suspense on the Mint tea page
Questionloading.tsxA Suspense in the page
What waits behind the fallbackThe whole page: here, the heading and the description too.Only what's inside it: here, the stock or the reviews.
What the first HTML showedLoading the tea page… and nothing else of the page.The heading, the description and two small fallbacks.
How you add itA file beside page.tsx. The page itself can await.A <Suspense> you write around the component that waits.
When it fitsWhen nothing on the page can show before its data.Most pages: when part of the page can show at once.

The docs recommend a Suspense close to the data. A loading.tsx high in the tree is a valid boundary, but then the whole page shows one loading state instead of streaming part by part. The two also work together: a loading.tsx for the moment the page itself waits, and a Suspense inside the page for the parts that wait longer.

Where to place a boundary

A boundary covers the waits inside it. So it goes above the component that waits, in the component that renders it. The most common mistake is the opposite: a Suspense inside the async component, around its own JSX. Pick each version:

Playground: where does the boundary go?
The Mint tea page, in this versionNext.js
app/teas/mint/lib/
app/teas/mint/page.tsx
import { Suspense } from "react";import { getReviews, getStock } from "@/lib/shop"; async function Stock() {  const stock = await getStock("mint");  return <p>{stock} bags left</p>;} async function Reviews() {  const reviews = await getReviews("mint");  return (    <Suspense fallback={<p>Loading reviews…</p>}>      <ul>        {reviews.map((review) => (          <li key={review}>{review}</li>        ))}      </ul>    </Suspense>  );} export default function MintTeaPage() {  return (    <main>      <h1>Mint tea</h1>      <p>Fresh mint leaves, picked in May.</p>      <Suspense fallback={<p>Checking stock…</p>}>        <Stock />      </Suspense>      <h2>Reviews</h2>      <Reviews />    </main>  );}
next build failed · 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 ...Error: Route "/teas/mint": Next.js encountered uncached or runtime data during prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. Ways to fix this:  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)  - [block] Set `export const instant = false` to allow a blocking route Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic    at main (<anonymous>)    at body (<anonymous>)    at html (<anonymous>)To get a more detailed stack trace and pinpoint the issue, try one of the following:  - Start the app in development mode by running `next dev`, then open "/teas/mint" in your browser to investigate the error.  - Rerun the production build with `next build --debug-prerender` to generate better stack traces.Error occurred prerendering page "/teas/mint". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /teas/mint/page: /teas/mint, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: null
Try it yourself
Inside the component

The Suspense is inside Reviews, after its await, and nothing above Reviews can wait for it. The build stops: Next.js encountered uncached or runtime data during prerendering.

Each version was built with next build from Next.js 16.4.3. The two that built were run with next start and asked for /teas/mint; the first stopped the build, and its Terminal tab shows why. Reset goes back to the first version.

In Inside the component, Reviews waits on line 10, and only then returns the Suspense on line 12. While it waits, that Suspense doesn't exist yet. React looks up the tree for a boundary, and line 31 of the page renders <Reviews /> with none around it. With Cache Components on, the build stops: Next.js encountered uncached or runtime data during prerendering. The first fix the error lists, [stream], is a Suspense around the data access, which means above the component that waits.

Two halves. On the left, a crate labeled Reviews holds the card 1, await getReviews, and below it a smaller crate labeled Suspense, which only exists after the await; a red label says nothing above can wait. On the right, a crate labeled Suspense holds the fallback card Loading reviews and the card Reviews, which awaits; a green label says the fallback shows while it waits.1. await getReviews()2. too lateSuspenseReviewsnothing above can waitfallback: Loading reviews…Reviews: await getReviews()Suspensethe fallback shows while it waits
On the left, the Suspense is inside Reviews, after its await, so it doesn't exist yet while Reviews waits: the build stopped. On the right, it sits above Reviews, in the page, and its fallback shows while Reviews waits.

In Above the component, the Suspense on lines 29 to 31 wraps <Reviews /> in the page, and the first HTML has the heading, the description and both fallbacks. In loading.tsx for the page, the boundary is above the whole page, so nothing of the page is in the first part.

The same goes for anything that waits. An await at the top of a page or a layout puts everything below it behind the nearest boundary above. The docs call the fix pushing dynamic access down: let the component that needs the data await it, and wrap that component. Boundaries can also nest: an inner Suspense shows its own fallback once the outer one's content has arrived.

Passing a promise to a Client Component

A Client Component can't be async, but it can wait for a promise with use. Composing Server and Client Components showed the pattern: a Server Component starts a read without await and passes the promise down. It streams like any other part. Here the reviews get a Hide reviews button:

The reviews as a Client Component, from a promiseNext.js
app/teas/mint/lib/
app/teas/mint/page.tsx
import { Suspense } from "react";import { getReviews, getStock } from "@/lib/shop";import ReviewList from "./review-list"; async function Stock() {  const stock = await getStock("mint");  return <p>{stock} bags left</p>;} export default function MintTeaPage() {  const reviews = getReviews("mint");  return (    <main>      <h1>Mint tea</h1>      <p>Fresh mint leaves, picked in May.</p>      <Suspense fallback={<p>Checking stock…</p>}>        <Stock />      </Suspense>      <h2>Reviews</h2>      <Suspense fallback={<p>Loading reviews…</p>}>        <ReviewList reviews={reviews} />      </Suspense>    </main>  );}
Recorded from Next.js 16.4.3

GET /teas/mint200

Sent with the request
user-agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
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 17 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/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <script src="/_next/static/chunks/3sh19eyk9agcw.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <!--&-->
    <main>
      <h1>Mint tea</h1>
      <p>Fresh mint leaves, picked in May.</p>
      <!--$?-->
      <template id="B:0"></template>
      <p>Checking stock…</p>
      <!--/$-->
      <h2>Reviews</h2>
      <!--$?-->
      <template id="B:1"></template>
      <p>Loading reviews…</p>
      <!--/$-->
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <!--/&-->
    <script>…</script>
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <div hidden="" id="S:0">
      <p>4<!-- --> bags left</p>
    </div>
    <script>…</script>
    <div hidden="" id="S:1">
      <button>Hide reviews</button>
      <ul>
        <li>Cool and fresh.</li>
        <li>Lovely after dinner.</li>
      </ul>
    </div>
    <script>…</script>
    <script>…</script>
  </body>
</html>

Line 11 of app/teas/mint/page.tsx calls getReviews("mint") without await, so the page itself never waits. Line 21 passes the promise to ReviewList, inside the Suspense on lines 20 to 22. In app/teas/mint/review-list.tsx, line 6 reads it with use(reviews), which suspends ReviewList until the promise settles. The Suspense has to sit above the component that calls use(), as for any component that waits. The promise must settle to a value that can be serialized, sent from the server to the browser as data, like the props in Composing Server and Client Components: here a string[], a list of strings, which is. The Network tab shows the same pieces as the first Mint tea page: the shell with both fallbacks, then the stock, then a hidden part with the button and both reviews.

Open the Steps tab, recorded in real Chrome: clicking Hide reviews hides the list, with no request, because open is state in the browser. The recording also checked the page's JavaScript files: they contain Hide reviews, from the Client Component's code, but not Cool and fresh. The reviews are data. They came with the page's response, not with its code.

Streaming and search engines

A response starts with its status code and headers. With streaming, they leave together with the first part, before the parts that wait have run:

A row of cards joined by arrows: 200 and the headers, then the shell, then the stock, then the reviews. A label under the first card says fixed once sent; a label under the rest says still on its way.200 + headersthe shellthe stockthe reviewsfixed once sentstill on its wayone response
The status code and the headers go first, before any HTML. By the time a part further down runs notFound() or redirect(), they have already left: the response stays 200, and Next.js handles it inside the HTML.

So a page can't change its status after streaming has started. When a streamed part calls notFound(), the response stays 200, and Next.js adds a noindex tag so search engines leave the page out, as Not found, forbidden and unauthorized recorded. A redirect() there can't send a 307 either: the response stays 200 and tells the browser to go to the new address, as Redirects showed. So a check that must answer a real 404 has to finish before the response starts to stream. A page can call notFound() before it returns any Suspense and before the response starts, or the check can run earlier still, in proxy.ts, which the docs suggest as one option.

The content is a different story. Every part is in the same HTML response, so a program that reads the whole response gets the stock and the reviews too. The docs say streaming doesn't hurt search: the page is still rendered on the server. Some bots need more. Programs that make link previews often read only the <head> and run no JavaScript. Next.js calls them HTML-limited bots, and makes them wait for generateMetadata, so the title is in the <head>: that's streaming metadata, from the Metadata API lesson.

The recorder also asked for the Mint tea page as Slackbot, one of those bots. Pick from Slackbot in the Network tab of the Mint tea page's recording, the one with a Suspense above each part. In this recording, Slackbot received the same visible markup in its first part as Chrome did, with both fallbacks, then the stock, then the reviews. Its response headers are shorter, though: the three whose names start with x-nextjs- in Chrome's response aren't there, so this first part was rendered for Slackbot's request rather than sent from the prerendered shell.

That matches what the docs say about these bots: with Cache Components, HTML-limited bots skip the prerendered shell, and Next.js renders the page for their request, so it can put the metadata in the <head>, and the rest can still stream. This page has no generateMetadata, so nothing held the response back. Each row is one part of a response:

What can still change after the first part has left
Part of the responseCan it still change?What that means
The status codeNo. It leaves with the first part: 200.A notFound() later adds a noindex tag instead.
The response headersNo. They leave with the status code.Set them before streaming starts, for example in proxy.ts or next.config.ts.
The page's contentYes. Each part arrives in the same response.It's all in the HTML, in the order it was ready.
The title and meta tagsYes, for browsers: streaming metadata.HTML-limited bots get them in the <head>: Next.js waits for generateMetadata.

Planning the Mint tea page's boundaries

Planning boundaries comes down to a few questions for each part of a page:

  • Does it wait for anything read per request? If not, leave it outside every boundary: it goes in the static shell.
  • What should the visitor see meanwhile? That's the fallback. Give it the part's shape, as a skeleton does, so nothing jumps.
  • Should it appear with the parts next to it, or on its own? One boundary for parts that belong together; one each for parts that don't.
  • Does the page need a real status code? Finish that check before the response starts to stream: before the first Suspense, or in proxy.ts.

Sort these parts of the Mint tea page's versions: are they in the first part, or streamed in later?

In the shell, or streamed?
  • <h1>Mint tea</h1> in the page with two Suspenses
  • Checking stock…
  • 4 bags left
  • The two reviews
  • <h1>Mint tea</h1> in the page that loading.tsx covers
  • <h2>Reviews</h2> between the two Suspenses
Try it yourself
0 of 6 correct

Sort each part: is it in the first part of the HTML, the static shell, or does it arrive later?

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

Common mix-ups

  • "Streaming needs code of its own to send HTML in pieces." No. A Suspense or a loading.tsx is all it takes. Next.js and React do the sending.
  • "Parts arrive in the order they appear on the page." No. With the waits swapped, the reviews arrived before the stock.
  • "A Suspense inside the component that waits covers the wait." No. It only exists after the wait. The recorded build stopped.
  • "Each part inside one boundary shows as soon as it arrives." No. A boundary shows its content once everything inside it is ready.
  • "Streamed content is hidden from search engines." No. It's in the same HTML response. Slackbot's response held the reviews too.
  • "A streamed page can still answer 404." Not once the first part has left. It stays 200, with a noindex tag.

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 database: the stock waits less than the reviews, unless the exercise says otherwise.

Exercise 1 · Warm-upWhich fallback goes first

A visitor opens this page. Which of the two fallbacks is replaced first?

Starter codeTSX
import { Suspense } from "react";
import { getReviews, getStock } from "@/lib/shop";

async function Stock() {
  const stock = await getStock("mint");
  return <p>{stock} bags left</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>Fresh mint leaves, picked in May.</p>
      <Suspense fallback={<p>Checking stock…</p>}>
        <Stock />
      </Suspense>
      <h2>Reviews</h2>
      <Suspense fallback={<p>Loading reviews…</p>}>
        <Reviews />
      </Suspense>
    </main>
  );
}

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

    Exercise 2 · Warm-upOne boundary

    What does the first part of the HTML show in place of the stock and the reviews?

    Starter codeTSX
    import { Suspense } from "react";
    import { getReviews, getStock } from "@/lib/shop";
    
    async function Stock() {
      const stock = await getStock("mint");
      return <p>{stock} bags left</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>Fresh mint leaves, picked in May.</p>
          <Suspense fallback={<p>Loading stock and reviews…</p>}>
            <Stock />
            <h2>Reviews</h2>
            <Reviews />
          </Suspense>
        </main>
      );
    }
    

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

      Exercise 3 · PracticeFix the boundary

      This page stops the build. Move the boundary so it builds. Which fallback does the first HTML then show under the Reviews heading?

      Starter codeTSX
      import { Suspense } from "react";
      import { getReviews, getStock } from "@/lib/shop";
      
      async function Stock() {
        const stock = await getStock("mint");
        return <p>{stock} bags left</p>;
      }
      
      async function Reviews() {
        const reviews = await getReviews("mint");
        return (
          <Suspense fallback={<p>Loading reviews…</p>}>
            <ul>
              {reviews.map((review) => (
                <li key={review}>{review}</li>
              ))}
            </ul>
          </Suspense>
        );
      }
      
      export default function MintTeaPage() {
        return (
          <main>
            <h1>Mint tea</h1>
            <p>Fresh mint leaves, picked in May.</p>
            <Suspense fallback={<p>Checking stock…</p>}>
              <Stock />
            </Suspense>
            <h2>Reviews</h2>
            <Reviews />
          </main>
        );
      }
      

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

        Exercise 4 · PracticeSwap the waits

        This is lib/shop.ts with its waits changed. With the Mint tea page above, which hidden part arrives first: the stock's or the reviews'?

        Starter codeTypeScript
        import { connection } from "next/server";
        
        function wait(ms: number) {
          return new Promise((resolve) => setTimeout(resolve, ms));
        }
        
        // A stand-in for the shop's database, read for each request. Each read waits
        // on purpose: the stock 600 ms, the reviews 200 ms.
        export async function getStock(id: string) {
          await connection();
          await wait(600);
          return id === "mint" ? 4 : 0;
        }
        
        export async function getReviews(id: string) {
          await connection();
          await wait(200);
          return id === "mint" ? ["Cool and fresh.", "Lovely after dinner."] : [];
        }
        

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

          Exercise 5 · ChallengeShow the heading at once

          With this page and its loading.tsx, the first HTML shows only Loading the tea page…. Change it so the heading and the description show at once, with one boundary per part. How many fallbacks does the first HTML then show?

          Starter codeTSX
          import { getReviews, getStock } from "@/lib/shop";
          
          export default async function MintTeaPage() {
            const [stock, reviews] = await Promise.all([getStock("mint"), getReviews("mint")]);
            return (
              <main>
                <h1>Mint tea</h1>
                <p>Fresh mint leaves, picked in May.</p>
                <p>{stock} bags left</p>
                <h2>Reviews</h2>
                <ul>
                  {reviews.map((review) => (
                    <li key={review}>{review}</li>
                  ))}
                </ul>
              </main>
            );
          }
          

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

            Exercise 6 · ChallengeWhat a bot gets

            Slackbot reads the HTML and runs no JavaScript. When it asks for this page, is Cool and fresh. anywhere in the HTML it gets?

            Starter codeTSX
            import { Suspense } from "react";
            import { getReviews, getStock } from "@/lib/shop";
            
            async function Stock() {
              const stock = await getStock("mint");
              return <p>{stock} bags left</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>Fresh mint leaves, picked in May.</p>
                  <Suspense fallback={<p>Checking stock…</p>}>
                    <Stock />
                  </Suspense>
                  <h2>Reviews</h2>
                  <Suspense fallback={<p>Loading reviews…</p>}>
                    <Reviews />
                  </Suspense>
                </main>
              );
            }
            

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

              Check your understanding

              The streaming quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In the recorded HTML for /teas/mint, what comes first?

                Choose an answer to see the explanation.

              2. Question 2 of 7What decides the order in which the streamed parts arrive?

                Choose an answer to see the explanation.

              3. Question 3 of 7Reviews awaits its data, then returns a Suspense around its list, and nothing above it is a boundary. What happens with Cache Components on?

                Choose an answer to see the explanation.

              4. Question 4 of 7When does loading.tsx fit better than a Suspense in the page?

                Choose an answer to see the explanation.

              5. Question 5 of 7One Suspense wraps both the stock and the reviews. When does the visitor see the stock?

                Choose an answer to see the explanation.

              6. Question 6 of 7A streamed page calls notFound() after the first part has left. What status code does the response have?

                Choose an answer to see the explanation.

              7. Question 7 of 7A Client Component reads a promise with use(). Where does its Suspense go?

                Choose an answer to see the explanation.

              Key takeaways

              • Streaming sends one HTML response in pieces: the static shell first, with the static and cached content and a fallback for each boundary that waits for uncached or request-time work, then that boundary's content when it's ready.
              • Parts arrive in the order their data is ready, not in page order. A boundary shows its content once everything inside it is ready.
              • Put each Suspense above the component that waits. One inside it comes too late: with Cache Components, the build stops.
              • loading.tsx is one boundary around the whole page. A Suspense close to the data lets the rest of the page show at once.
              • A promise passed to a Client Component streams the same way; the boundary goes above the component that calls use().
              • The status code leaves with the first part. Every part is in the HTML response, and HTML-limited bots get their metadata in the <head>.

              Remember the one-liner.
              Put a Suspense above each part that waits: the shell goes out at once, and each part follows as soon as it's ready.

              Coming next: Fetching in the browser. It covers the data that really belongs to the browser, like live updates and infinite lists.

              CompleteFrontend Clear concepts. Working examples.