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

Request-time APIs

Read cookies, headers, params and searchParams in Next.js, wait for a visit with connection(), and keep each one behind Suspense so the page still prerenders.

By the end, you can
  • 01
    Read what a request carriesUse cookies(), headers(), params and searchParams, and say what each gives you and where you can change it.
  • 02
    Wait for a request on purposeUse connection() for values that must be new for each visit, and take a first look at draftMode().
  • 03
    Keep the shellExplain why these APIs return promises, read them inside Suspense, and predict what each does to the route's symbol.

A cart page that waits for the visitor

Some of what a page shows only exists once someone asks for it: the cookie their browser sends, the language it asks for, the address they typed. Next.js hands each of them to your Server Components through a request-time API, and each one is a promise. Here is the tea shop's cart page, built with real Next.js 16.4.3 and asked for twice: once with no cookie, and once with the cookie cart=mint, as a browser sends it after the visitor adds Mint tea.

The tea shop's cart page reads a cookieNext.js
app/api/draft/language/cart/news/stock/teas/[id]/
app/cart/page.tsx
import { Suspense } from "react";import { cookies } from "next/headers";import { getTeas } from "../teas"; async function CartItem() {  const cookieStore = await cookies();  const id = cookieStore.get("cart")?.value;  const tea = (await getTeas()).find((tea) => tea.id === id);  return <p>{tea ? `In your cart: ${tea.name}` : "Your cart is empty."}</p>;} export default function CartPage() {  return (    <main>      <h1>Your cart</h1>      <Suspense fallback={<p>Loading your cart…</p>}>        <CartItem />      </Suspense>    </main>  );}
Recorded from Next.js 16.4.3

GET /cart200

Sent with the request
cookie cart=mint
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 14 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="/cart">Cart</a></nav>
    <!--&-->
    <!--&-->
    <main>
      <h1>Your cart</h1>
      <!--$?-->
      <template id="B:0"></template>
      <p>Loading your cart…</p>
      <!--/$-->
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script>…</script>
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <div hidden="" id="S:0">
      <p>In your cart: Mint tea</p>
    </div>
    <script>…</script>
    <script>…</script>
  </body>
</html>

In app/cart/page.tsx, line 2 imports cookies from next/headers. CartItem, on lines 5 to 10, waits for cookies() on line 6 and gets the request's cookie store. Line 7 reads the cookie named cart: .get("cart") gives an object with its value, or undefined when there's no such cookie. Line 8 finds that tea, and line 9 shows it. The page puts CartItem inside Suspense on lines 16 to 18, with the fallback Loading your cart….

The Network tab is open on the request with Mint tea in the cart. Under the response, Sent with the request shows the header the recorder sent, cookie: cart=mint, and the page says In your cart: Mint tea. Pick the first /cart above the page, sent with no cookie: it says Your cart is empty. Both start with the same HTML: the heading and Loading your cart…. The Terminal tab says why: ◐ /cart. The heading was prerendered, and the cart is made for each request.

A window titled request holds four cards: GET /teas/mint, ?sort=price, cookie: cart=mint, and accept-language: fr. An arrow goes from each to the API that reads it: await params, await searchParams, await cookies(), await headers().requestGET /teas/mint?sort=pricecookie: cart=mintaccept-language: frawait paramsawait searchParamsawait cookies()await headers()
A request for /teas/mint?sort=price, as a page at app/teas/[id]/page.tsx would get it. The dynamic part of the address arrives as params, the query string as searchParams, the cookie header through cookies(), and every header through headers(). None of them exists until a request comes in.
Real-life analogyA name on a coffee cup

A café sets up its counter, cups and pastries before it opens. It can't write your name on your cup until you order, so the cup waits with a blank space. Everything else is ready the moment you walk in.

In real life: The counter, set up before the café opens
In Next.js: The static shell, made during next build
In real life: Your name, which you give when you order
In Next.js: A cookie your browser sends with the request: cookies()
In real life: The language you order in
In Next.js: The accept-language header: headers()
In real life: The blank space on the cup for your name
In Next.js: A promise, which settles once a request comes in
The main idea

Request-time APIs give your code what only a request carries, and each one returns a promise. Awaiting it is where a component waits for a request. Read them inside Suspense, as deep in the page as you can, so everything else stays in the static shell.

Reading cookies() and headers()

A cookie is a small piece of text a browser keeps for a site and sends back with every request to it, in the cookie header. cookies() reads them for you. Its store has three methods for reading:

  • .get("cart") gives { name, value }, or undefined.
  • .has("cart") says whether the cookie is there.
  • .getAll() gives every cookie, as a list.

While a Server Component renders, you can only read cookies. The docs explain why: HTTP can't add a cookie once a response has started streaming. To set one, use .set() in a Server Action or a Route Handler, whose code runs before its response starts.

headers(), also from next/headers, gives every header of the request as a read-only Headers object, the same kind fetch uses. The shop's home page greets visitors in the language their browser asks for first:

The home page reads the accept-language headerNext.js
app/api/draft/language/cart/news/stock/teas/[id]/
app/page.tsx
import { Suspense } from "react";import { headers } from "next/headers"; const greetings: Record<string, string> = { en: "Welcome!", fr: "Bienvenue !", de: "Willkommen!" }; async function Greeting() {  const headerList = await headers();  const language = headerList.get("accept-language")?.slice(0, 2) ?? "en";  return <p>{greetings[language] ?? greetings.en}</p>;} export default function Home() {  return (    <main>      <h1>Tea shop</h1>      <Suspense fallback={<p>Choosing your language…</p>}>        <Greeting />      </Suspense>    </main>  );}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
Tea shop

Bienvenue !

Sent with accept-language: fr-FR,fr;q=0.9. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Line 4 holds three greetings. Greeting waits for headers() on line 7, and line 8 reads the accept-language header, which a French browser sends as fr-FR,fr;q=0.9, and keeps its first two letters. Line 9 shows that language's greeting, or the English one. The Browser tab shows the request from a French browser: Bienvenue !. The plain request, which didn't ask for French or German, got Welcome!. Both pages' shells hold Choosing your language…, the fallback on line 16.

Route Handlers can use both APIs too. app/api/language/route.ts answers with the header it got: asked with accept-language: fr-FR,fr;q=0.9, it sent back {"language":"fr-FR,fr;q=0.9"}. In a Route Handler you can also read them from the request argument, as Route Handlers showed. Each row is one API, with what you get and where you can change it:

What each request-time API gives you
You writeWhat you getWhere you can change it
await cookies()The request's cookies: .get("cart")?.value, .has("cart"), .getAll().Only in a Server Action or a Route Handler, with .set() and .delete().
await headers()The request's headers, as a read-only Headers object: .get("accept-language").Nowhere: they're the request's. A Route Handler or a proxy sets headers on its own response.
await paramsThe address's dynamic parts: { id: "mint" } for /teas/mint.Nowhere: they come from the address.
await searchParamsThe query string as an object: { sort: "price" } for ?sort=price.Nowhere: they come from the address.

params and searchParams

The address is part of the request too. A page gets two props for it, and both are promises, like cookies():

  • params: the address's dynamic parts. For app/teas/[id]/page.tsx and the address /teas/mint, it settles with { id: "mint" } (Dynamic routes).
  • searchParams: the query string, the part after ?, as a plain object. For /teas?sort=price, it settles with { sort: "price" } (Search params).
One tea's page passes params down to the part that reads itNext.js
app/api/draft/language/cart/news/stock/teas/[id]/
app/teas/[id]/page.tsx
import { Suspense } from "react";import { getTeas } from "../../teas"; async function TeaName({ params }: { params: PageProps<"/teas/[id]">["params"] }) {  const { id } = await params;  const tea = (await getTeas()).find((tea) => tea.id === id);  return <h1>{tea ? tea.name : "No such tea"}</h1>;} export default function TeaPage({ params }: PageProps<"/teas/[id]">) {  return (    <main>      <Suspense fallback={<h1>Loading the tea…</h1>}>        <TeaName params={params} />      </Suspense>      <p>Packed by hand, in bags of 20.</p>    </main>  );}
Recorded from Next.js 16.4.3

GET /teas/mint200

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 14 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="/cart">Cart</a></nav>
    <!--&-->
    <!--&-->
    <!--&-->
    <main>
      <!--$?-->
      <template id="B:0"></template>
      <h1>Loading the tea…</h1>
      <!--/$-->
      <p>Packed by hand, in bags of 20.</p>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <!--/&-->
    <script>…</script>
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <div hidden="" id="S:0">
      <h1>Mint tea</h1>
    </div>
    <script>…</script>
    <script>…</script>
  </body>
</html>

TeaPage, on line 10, gets params but doesn't wait for it. Line 14 passes the promise down to TeaName, inside Suspense, and line 5 waits for it there. So the paragraph on line 16 doesn't wait: in the Network tab, the first part of the HTML for /teas/mint already has Packed by hand, in bags of 20., under the fallback Loading the tea…, and Mint tea arrives after. PageProps<"/teas/[id]"> is a type Next.js writes for each route, so id is checked.

app/teas/page.tsx does the same with searchParams: it passes the promise to TeaList, which sorts the teas when sort is price. Pick /teas?sort=price in the Browser tab: Mint tea and Lemon tea, at $3, come before Green tea. A Client Component can't be async; it reads these promises with React's use, as Dynamic routes showed. And when a route lists its params ahead of time with generateStaticParams, the build can make those pages whole; generateStaticParams covers it.

connection(): waiting for a request

Some values don't come from the request, but must still be new for each visit: the current time, a random number, a row from a database driver that answers at once. connection(), from next/server, makes the code after it wait for a real request. It reads nothing; its promise settles with no value. The shop's stock page says when it checked the stock:

The stock page waits for a visit before it reads the clockNext.js
app/api/draft/language/cart/news/stock/teas/[id]/
app/stock/page.tsx
import { Suspense } from "react";import { connection } from "next/server"; async function CheckedAt() {  await connection();  const time = new Date().toISOString().slice(11, 23);  return <p>Stock checked at {time} UTC.</p>;} export default function StockPage() {  return (    <main>      <h1>Stock</h1>      <Suspense fallback={<p>Checking the stock…</p>}>        <CheckedAt />      </Suspense>    </main>  );}
Recorded from Next.js 16.4.3

GET /stock200

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 14 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="/cart">Cart</a></nav>
    <!--&-->
    <!--&-->
    <main>
      <h1>Stock</h1>
      <!--$?-->
      <template id="B:0"></template>
      <p>Checking the stock…</p>
      <!--/$-->
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script>…</script>
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <div hidden="" id="S:0">
      <p>Stock checked at <!-- -->07:27:40.398<!-- --> UTC.</p>
    </div>
    <script>…</script>
    <script>…</script>
  </body>
</html>

Line 5 waits for connection(), and line 6 reads the clock: the hours, minutes, seconds and milliseconds, in UTC. The shop was asked for /stock twice. Open the Network tab for either visit: the first part of the HTML, the shell made during the build, holds only the fallback, Checking the stock…. The time comes in the streamed part, read when that visit arrived. The build itself never read the clock: its output has no time in it.

Without line 5, the clock would be read during the build. Next.js doesn't let that happen by accident:

The stock page without connection() (it fails on purpose)Next.js
app/cart/stock/
app/stock/page.tsx
import { Suspense } from "react"; async function CheckedAt() {  const time = new Date().toISOString().slice(11, 23);  return <p>Stock checked at {time} UTC.</p>;} export default function StockPage() {  return (    <main>      <h1>Stock</h1>      <Suspense fallback={<p>Checking the stock…</p>}>        <CheckedAt />      </Suspense>    </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 "/stock": Next.js encountered the unstable value `new Date()` while prerendering. This value can change between renders, so it must be either prerendered or computed later. Ways to fix this:  - [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`) before this call  - [cache] Prerender and cache the value with `"use cache"`  - [client] Render the value on the client with `"use client"`  - [measure] If the value is for telemetry, use a timing API such as `performance.now()` Learn more: https://nextjs.org/docs/messages/blocking-prerender-current-time    at d (app/stock/page.tsx:4:16)  2 |  3 | async function CheckedAt() {> 4 |   const time = new Date().toISOString().slice(11, 23);    |                ^  5 |   return <p>Stock checked at {time} UTC.</p>;  6 | }  7 |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 "/stock" 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 "/stock". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /stock/page: /stock, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: null

The build stops: Route "/stock": Next.js encountered the unstable value new Date() while prerendering. A time read during the build would be the same for every visitor, so Next.js asks you to choose. Its first fix, [dynamic], is await connection(). Its second, [cache], keeps one value for everyone with "use cache".

You don't need connection() after a request-time API, or before an awaited fetch that isn't cached: those already wait. Next.js's docs also describe io(), from next/cache, added in Next.js 16.3: it keeps a value out of the static shell the same way, but the result can still be cached and prefetched. The docs suggest io() for that, and connection() when the code must wait for a real visit.

draftMode(): a first look

Many sites keep their articles in a CMS, a content management system where editors write and publish. Editors want to see a post on the real site before it's published. draftMode(), from next/headers, is for that: its isEnabled says whether this browser turned draft mode on.

The news page shows the draft when draft mode is onNext.js
app/api/draft/language/cart/news/stock/teas/[id]/
app/news/page.tsx
import { draftMode } from "next/headers"; export default async function NewsPage() {  const { isEnabled } = await draftMode();  return (    <main>      <h1>Tea news</h1>      <p>{isEnabled ? "Draft: Our autumn teas arrive on Monday." : "Our autumn teas arrive soon."}</p>    </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/newsTea shop
Home Cart
Tea news

Draft: Our autumn teas arrive on Monday.

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (3), without the files it loaded

  1. GET/news200a whole page
  2. GET/200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /news
  3. GET/cart200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /news

Line 4 waits for draftMode(), and line 8 shows the draft when isEnabled is true. The Steps tab is real Chrome:

  1. It opens /news and sees Our autumn teas arrive soon.
  2. It opens /api/draft?secret=…&slug=autumn-teas, the address an editor gets from the CMS. That Route Handler calls draft.enable(), and its response sets a cookie, __prerender_bypass.
  3. It opens /news again, and now sees Draft: Our autumn teas arrive on Monday.

In the Terminal tab, /news is ○, prerendered as static content, even though it reads draftMode() at the top with no Suspense. Every other visitor gets that page. The docs explain the second visit: the bypass cookie tells Next.js to skip the prerendered page and render it for that request. Its value is random bytes that next build makes, so it can't be guessed. (Next.js 16.4.3 keeps that value in .next/cache and can reuse it for up to 14 days when that folder is kept between builds, outside Docker.)

Only editors should get that cookie. A Route Handler is a public address: anyone who finds /api/draft can open it. So it checks two things before it calls draft.enable():

The draft Route Handler checks a secret and the postNext.js
app/api/draft/language/cart/news/stock/teas/[id]/
app/api/draft/route.ts
import { createHash, timingSafeEqual } from "node:crypto";import { draftMode } from "next/headers";import { getPost } from "../../cms"; function isDraftSecret(given: string) {  const expected = process.env.DRAFT_SECRET ?? "";  const a = createHash("sha256").update(given).digest();  const b = createHash("sha256").update(expected).digest();  return expected.length >= 32 && timingSafeEqual(a, b);} export async function GET(request: Request) {  const { searchParams } = new URL(request.url);  const post = await getPost(searchParams.get("slug") ?? "");  if (!isDraftSecret(searchParams.get("secret") ?? "") || !post) {    return new Response("Invalid secret or post.", { status: 401 });  }  const draft = await draftMode();  draft.enable();  return new Response(`Draft mode is on for ${post.title}.`);}
Recorded from Next.js 16.4.3

GET /api/draft?secret=guess&slug=autumn-teas401

Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type text/plain;charset=UTF-8

The body that arrived, as it arrived

Invalid secret or post.

Line 14 asks the CMS, in app/cms.ts, for the post named by slug. Line 15 checks the address's secret with isDraftSecret, on lines 5 to 10, and checks that the post exists. isDraftSecret turns both the secret and DRAFT_SECRET, a server environment variable, into SHA-256 hashes of 32 bytes, and compares them with timingSafeEqual, which takes the same time however much of a guess is right (!== would stop at the first difference). It also refuses a missing or short DRAFT_SECRET; a secret is strong because it was generated from random bytes, like this one, from openssl rand -hex 16. If either check fails, line 16 answers 401, and no cookie is set: the Network tab shows that answer for a wrong secret. With the editors' secret, the answer is 200, Draft mode is on for Our autumn teas., with the set-cookie header. The recording reads DRAFT_SECRET from .env.local; a real project keeps it in its host's settings, and .env* files out of git (Server-only code and environment variables). Draft mode and CMS previews builds the full flow: Preview links the CMS signs for one post with an expiry, turning draft mode off, and what it does to cached data.

Why they return promises

In Next.js 14 and earlier, cookies() and headers() returned their values at once, and params was a plain object. Next.js 15 made them promises, and kept the old way working for a while, with a warning. Next.js 16 removed it. The reason is the static shell. Classic server rendering waits for a request before it renders anything. But most of a page doesn't depend on the request, so Next.js wants to render it ahead of time. To do that, it has to know where your code waits for the request. A promise marks that place.

Two rows. During next build, with no request, cookies() gives a promise that doesn't settle, so the shell gets the fallback Loading your cart…. On a visit with the cookie cart=mint, the promise settles with cart=mint, and the shaded card In your cart: Mint tea is made for that visit.During next build: no requestcookies()not settled yetLoading your cart…A visit with cookie: cart=mintcookies()cart=mintIn your cart: Mint tea
The same await cookies() at two moments. During the build there's no request, so the promise can't settle, and the fallback goes in the static shell. On a visit, it settles with that visitor's cookies, and the part made from them (shaded) is streamed in.

During the build, there's no request, so the promise from cookies() doesn't settle. CartItem waits, and Next.js puts its fallback in the shell. On a visit, the promise settles with that request's cookies. Step through two visitors to the cart page, one with Mint tea in the cart and one with no cookie yet:

Playground: two visitors, one cart page
Who does whatMint tea in the cart
What the visitor seesafter step 1
tea-shop.example/cart

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 cart page

There's no request during the build, so the promise from cookies() can't settle. CartItem waits, and Next.js keeps the fallback in the static shell.

Each step is one thing that happens, in order. The screens are what the recording shows for /cart with and without the cookie: the first part of the HTML, then the whole page. Times aren't to scale. Reset goes back to the first visitor's first step.

Both visitors get the same shell after step 3: it was made once, during the build. Only step 5 differs. Use a request-time API without await, and TypeScript stops the build: Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'. It means you called .get on the promise, not on the cookie store it settles with. For older code, Next.js's codemod npx @next/codemod@canary next-async-request-api . adds most of the missing awaits.

Keeping them behind Suspense

Where you await a request-time API decides what can go in the shell. Each button below is one version of the cart page, asked for with cart=mint. Before you click, guess which version stops the build.

Playground: where should the cart page read its cookie?
The tea shop, with this version of app/cart/page.tsxNext.js
app/cart/
app/cart/page.tsx
import { cookies } from "next/headers";import { getTeas } from "../teas"; export default async function CartPage() {  const cookieStore = await cookies();  const id = cookieStore.get("cart")?.value;  const tea = (await getTeas()).find((tea) => tea.id === id);  return (    <main>      <h1>Your cart</h1>      <p>{tea ? `In your cart: ${tea.name}` : "Your cart is empty."}</p>    </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 "/cart": 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 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 "/cart" 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 "/cart". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /cart/page: /cart, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: null
Try it yourself
At the top, no Suspense

Line 5 waits for cookies() at the top of the page, with nothing to show meanwhile. There's no cookie during the build, so the build stops: Route "/cart" encountered uncached or runtime data during prerendering.

Every version was built with next build from Next.js 16.4.3 and asked for /cart with the cookie cart=mint. The Terminal shows the build's output, and the Network tab the HTML in the order it arrived. Reset goes back to the first version.

The first version's error has three parts:

  • Route "/cart": Next.js encountered uncached or runtime data during prerendering. Runtime data is data from the request, like a cookie.
  • A line naming what does that: fetch(...), cookies(), headers(), params, searchParams, or connection(), accessed outside of <Suspense>.
  • The fixes: [stream], a Suspense around the read; [cache], which is for fetches and database calls, not for cookies(); and [block], export const instant = false.

Its last line, Learn more, links to Next.js's docs. The docs' page Uncached data was accessed outside of <Suspense> covers the same error with request data.

Two cart pages. Left, the cookie is read at the top of the page: the heading and the cart are both shaded, made for each request, and nothing goes in the shell. Right, the cookie is read inside Suspense: the heading is plain, in the shell, and only the cart is shaded.at the topYour cartthe cartnothing for the shellin SuspenseYour cartthe cartthe heading is in the shell
Shaded parts wait for the request. Read cookies() at the top of the page, and the whole page waits for it: the build stops, unless loading.tsx or instant = false says what to do. Read it inside Suspense, and only that part waits.

The fixes don't keep the same amount of the page in the shell. With Suspense around CartItem, the shell has the heading. With loading.tsx (Loading UI), the build succeeds too, but the whole page waits behind one fallback: the shell holds only Loading your cart…. instant = false only turns off the check for a shell; the cookie, still read at the top, leaves none, so the route is ƒ /cart. So read the request as deep in the page as you can. When a parent gets the promise, as TeaPage does, pass it down to the component that needs the value, and await it there.

Caching can't wrap these APIs: "use cache" can't call cookies() or headers(). Read the value outside, and pass it to a cached function as an argument; Private and remote caches shows another way.

What each one does to the route

The shop has one page or Route Handler for each API. Open the first example's Terminal tab and find each route. Each row is one route of that recorded build:

The tea shop's routes, in its recorded build
RouteWhat it readsWhat the build prints
/cartcookies(), inside Suspense◐
/headers(), inside Suspense◐
/teassearchParams, inside Suspense◐
/teas/[id]params, inside Suspense, with no list of ids◐
/stockconnection(), inside Suspense◐
/newsdraftMode(), at the top of the page○
/api/languageheaders(), in a Route Handlerƒ
/api/draftdraftMode(), in a Route Handlerƒ

Every page that reads the request inside Suspense is ◐: a static shell, with the part that read the request streamed in. The App Router before Cache Components made the whole route dynamic as soon as one component read a cookie; with Cache Components on, only that part waits. The news page stays ○: draftMode() didn't make it wait. The two Route Handlers are ƒ, server-rendered on demand: each one reads the request, so it runs for each request. And the cart playground showed the last case: a page that reads the request at the top with instant = false is ƒ too. Partial Prerendering looks closer at what makes it into the shell.

Choosing the right API

Each job on a real page needs one of these APIs. Remember that the request comes from the visitor: anyone can change their own cookies and headers, so check a value before you trust it. Sort each job:

Which API does this job need?
  • Show the tea this visitor put in the cart
  • Greet the visitor in the language their browser asks for
  • Sort the teas by price for /teas?sort=price
  • Show the tea named in /teas/mint
  • Show the time the stock was checked, new for each visit
  • Let an editor see a news post before it's published
Try it yourself
0 of 6 correct

Sort each job on the tea shop's pages by the request-time API that does it.

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

Common mix-ups

  • "cookies() still works without await." No. Since Next.js 16 it's only a promise. Calling .get on it stops the build with a TypeScript error.
  • "Reading a cookie makes the whole page dynamic." No. With Cache Components, only the part inside Suspense waits: ◐ /cart keeps its heading in the shell.
  • "A Server Component can set a cookie while it renders." No. Set cookies in a Server Action or a Route Handler.
  • "headers() sets the response's headers." No. It reads the request's headers, and they're read-only.
  • "Every database query needs connection() first." No. An awaited query that isn't cached already waits for a request. connection() is for values like the time, which don't wait on their own.
  • "One Suspense around the whole page is as good as one around the cart." No. With loading.tsx, the shell held only the fallback; with Suspense around CartItem, it also held the heading.
  • "draftMode() makes a page dynamic." No. The news page built as ○ /news.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upRead the cart for Green tea

A visitor adds Green tea, so their browser sends cookie: cart=green. What does the cart page show once the cart has streamed in?

Starter codeTSX
import { Suspense } from "react";
import { cookies } from "next/headers";
import { getTeas } from "../teas";

async function CartItem() {
  const cookieStore = await cookies();
  const id = cookieStore.get("cart")?.value;
  const tea = (await getTeas()).find((tea) => tea.id === id);
  return <p>{tea ? `In your cart: ${tea.name}` : "Your cart is empty."}</p>;
}

export default function CartPage() {
  return (
    <main>
      <h1>Your cart</h1>
      <Suspense fallback={<p>Loading your cart…</p>}>
        <CartItem />
      </Suspense>
    </main>
  );
}

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

    Exercise 2 · Warm-upGreet a German visitor

    A German browser asks for the home page with accept-language: de-DE,de;q=0.9. What does the greeting say?

    Starter codeTSX
    import { Suspense } from "react";
    import { headers } from "next/headers";
    
    const greetings: Record<string, string> = { en: "Welcome!", fr: "Bienvenue !", de: "Willkommen!" };
    
    async function Greeting() {
      const headerList = await headers();
      const language = headerList.get("accept-language")?.slice(0, 2) ?? "en";
      return <p>{greetings[language] ?? greetings.en}</p>;
    }
    
    export default function Home() {
      return (
        <main>
          <h1>Tea shop</h1>
          <Suspense fallback={<p>Choosing your language…</p>}>
            <Greeting />
          </Suspense>
        </main>
      );
    }
    

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

      Exercise 3 · PracticeFind the missing word

      With this cart page, the build stops: Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'. Which word is missing on line 6?

      Starter codeTSX
      import { Suspense } from "react";
      import { cookies } from "next/headers";
      import { getTeas } from "../teas";
      
      async function CartItem() {
        const cookieStore = cookies();
        const id = cookieStore.get("cart")?.value;
        const tea = (await getTeas()).find((tea) => tea.id === id);
        return <p>{tea ? `In your cart: ${tea.name}` : "Your cart is empty."}</p>;
      }
      
      export default function CartPage() {
        return (
          <main>
            <h1>Your cart</h1>
            <Suspense fallback={<p>Loading your cart…</p>}>
              <CartItem />
            </Suspense>
          </main>
        );
      }
      

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

        Exercise 4 · PracticeMove the greeting into Suspense

        This home page reads headers() at the top, and the build stops. Move the read into Suspense. Which symbol does the build print for / then?

        Starter codeTSX
        import { headers } from "next/headers";
        
        const greetings: Record<string, string> = { en: "Welcome!", fr: "Bienvenue !", de: "Willkommen!" };
        
        export default async function Home() {
          const headerList = await headers();
          const language = headerList.get("accept-language")?.slice(0, 2) ?? "en";
          return (
            <main>
              <h1>Tea shop</h1>
              <p>{greetings[language] ?? greetings.en}</p>
            </main>
          );
        }
        

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

          Exercise 5 · ChallengePass the promise down

          This tea page awaits params on line 4, and the build stops for /teas/[id]. Fix it so that most of the page is in the static shell. Which line of the page is in the shell then, besides the fallback?

          Starter codeTSX
          import { getTeas } from "../../teas";
          
          export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {
            const { id } = await params;
            const tea = (await getTeas()).find((tea) => tea.id === id);
            return (
              <main>
                <h1>{tea ? tea.name : "No such tea"}</h1>
                <p>Packed by hand, in bags of 20.</p>
              </main>
            );
          }
          

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

            Exercise 6 · ChallengeA cart count in the layout

            The shop wants Cart (1) in the nav when the visitor has a tea in the cart. Change the root layout to read the cart cookie inside Suspense. The news page used to build as ○ /news. What does the build print for it now?

            Starter codeTSX
            import Link from "next/link";
            
            export const metadata = { title: "Tea shop" };
            
            export default function RootLayout({ children }: LayoutProps<"/">) {
              return (
                <html lang="en">
                  <body>
                    <nav>
                      <Link href="/">Home</Link> <Link href="/cart">Cart</Link>
                    </nav>
                    {children}
                  </body>
                </html>
              );
            }
            

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

              Check your understanding

              For each question, ask what exists during the build, and what only a request brings.

              The Request-time APIs quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does cookies() give you in a Server Component?

                Choose an answer to see the explanation.

              2. Question 2 of 7What happens when you build the tea shop with this home page?

                Read the code, then predictTSX
                import { headers } from "next/headers";
                
                const greetings: Record<string, string> = { en: "Welcome!", fr: "Bienvenue !", de: "Willkommen!" };
                
                export default async function Home() {
                  const headerList = await headers();
                  const language = headerList.get("accept-language")?.slice(0, 2) ?? "en";
                  return (
                    <main>
                      <h1>Tea shop</h1>
                      <p>{greetings[language] ?? greetings.en}</p>
                    </main>
                  );
                }
                

                Choose an answer to see the explanation.

              3. Question 3 of 7Why do cookies(), headers(), params and searchParams return promises?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does await connection() do?

                Choose an answer to see the explanation.

              5. Question 5 of 7The shop's news page reads draftMode() at the top, with no Suspense. What does the build print for it?

                Choose an answer to see the explanation.

              6. Question 6 of 7Where can you set a cookie in the App Router?

                Choose an answer to see the explanation.

              7. Question 7 of 7This is app/api/language/route.ts. What does the build print for it?

                Read the code, then predictTypeScript
                import { headers } from "next/headers";
                
                export async function GET() {
                  const language = (await headers()).get("accept-language") ?? "none";
                  return Response.json({ language });
                }
                

                Choose an answer to see the explanation.

              Key takeaways

              • cookies() and headers() read the request; params and searchParams read its address. All four are promises, and a Server Component awaits them.
              • You read cookies while rendering; you set them in a Server Action or a Route Handler. Headers from headers() are read-only.
              • connection() makes the code after it wait for a real visit, for values like the time that don't come from the request.
              • The promise marks where a component waits for the request. During the build it doesn't settle, so Next.js puts the fallback in the static shell.
              • Read the request inside Suspense, as deep as you can. At the top of a page with nothing around it, the build stops.
              • Pages that read the request in Suspense are ◐; Route Handlers that read it are ƒ; a page that only reads draftMode() stayed ○.

              Remember the one-liner.
              What only a request carries comes as a promise; await it inside Suspense, and the rest of the page stays in the shell.

              Coming next: Partial Prerendering. One page, a static shell sent at once and dynamic parts streamed in: what makes it into the shell, and how it looks in the network tab.

              CompleteFrontend Clear concepts. Working examples.