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.
- 01Read what a request carriesUse cookies(), headers(), params and searchParams, and say what each gives you and where you can change it.
- 02Wait for a request on purposeUse connection() for values that must be new for each visit, and take a first look at draftMode().
- 03Keep 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.
app/cart/page.tsximport { 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> );}GET /cart200
- Sent with the request
cookiecart=mint
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 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.
/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.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-languageheader: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
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.
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. Forapp/teas/[id]/page.tsxand 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).
app/teas/[id]/page.tsximport { 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> );}GET /teas/mint200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 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:
app/stock/page.tsximport { 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> );}GET /stock200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 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:
app/stock/page.tsximport { 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▲ 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: nullThe 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.
app/news/page.tsximport { 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> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
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
GET/news200a whole pageGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /news
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:
- It opens
/newsand seesOur autumn teas arrive soon. - It opens
/api/draft?secret=…&slug=autumn-teas, the address an editor gets from the CMS. That Route Handler callsdraft.enable(), and its response sets a cookie,__prerender_bypass. - It opens
/newsagain, and now seesDraft: 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():
app/api/draft/route.tsimport { 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}.`);}GET /api/draft?secret=guess&slug=autumn-teas401
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchcontent-typetext/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.
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:
Mint tea in the cartafter step 1A blank page
React isn't running yet: clicks do nothing.
The page isn't complete yet.
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.
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.
app/cart/page.tsxNext.jsapp/cart/page.tsximport { 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▲ 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: nullLine 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.
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, orconnection(), accessed outside of<Suspense>. - The fixes:
[stream], aSuspensearound the read;[cache], which is for fetches and database calls, not forcookies(); 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.
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:
| Route | What it reads | What the build prints |
|---|---|---|
/cart | cookies(), inside Suspense | ◐ |
/ | headers(), inside Suspense | ◐ |
/teas | searchParams, inside Suspense | ◐ |
/teas/[id] | params, inside Suspense, with no list of ids | ◐ |
/stock | connection(), inside Suspense | ◐ |
/news | draftMode(), at the top of the page | ○ |
/api/language | headers(), in a Route Handler | ƒ |
/api/draft | draftMode(), 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:
- 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
Sort each job on the tea shop's pages by the request-time API that does it.
Common mix-ups
- "
cookies()still works withoutawait." No. Since Next.js 16 it's only a promise. Calling.geton it stops the build with a TypeScript error. - "Reading a cookie makes the whole page dynamic." No. With Cache Components, only the part inside
Suspensewaits:◐ /cartkeeps 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
Suspensearound the whole page is as good as one around the cart." No. Withloading.tsx, the shell held only the fallback; withSuspensearoundCartItem, 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.
A visitor adds Green tea, so their browser sends cookie: cart=green. What does the cart page show once the cart has streamed in?
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>
);
}
In your cart: Green tea. Line 7 gets green, line 8 finds Green tea, and line 9 shows its name. The recorded request with cookie: cart=green got exactly that, streamed in after Loading your cart….
A German browser asks for the home page with accept-language: de-DE,de;q=0.9. What does the greeting say?
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>
);
}
Willkommen!. Line 8 turns de-DE,de;q=0.9 into de, and line 9 shows greetings.de. The recorded request from a German browser got that greeting.
With this cart page, the build stops: Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'. Which word is missing on line 6?
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>
);
}
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>
);
}
await, on line 6: const cookieStore = await cookies();. Without it, cookieStore is the promise, which has no .get. The recorded build stops in TypeScript: Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'. With await, it builds as ◐ /cart.
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?
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>
);
}
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>
);
}
◐ /. Reading headers() at the top stops the build: Route "/" encountered uncached or runtime data during prerendering. With the read inside Greeting, in Suspense, the heading and the fallback are in the shell, and the recorded build prints ◐ /.
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?
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>
);
}
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>
);
}
Packed by hand, in bags of 20. Awaiting params at the top stops the build for /teas/[id]. Passed down to TeaName inside Suspense, only the name waits: the recorded first part of the HTML for /teas/mint has the fallback and the paragraph.
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?
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>
);
}
import { Suspense } from "react";
import { cookies } from "next/headers";
import Link from "next/link";
export const metadata = { title: "Tea shop" };
async function CartLink() {
const hasTea = (await cookies()).has("cart");
return <Link href="/cart">Cart ({hasTea ? 1 : 0})</Link>;
}
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<nav>
<Link href="/">Home</Link>{" "}
<Suspense fallback={<Link href="/cart">Cart</Link>}>
<CartLink />
</Suspense>
</nav>
{children}
</body>
</html>
);
}
◐ /news. The layout's CartLink reads cookies() inside Suspense, and every page renders inside the layout, so every page gets that hole: even /news and /_not-found are ◐. The recorded nav says Cart (0) with no cookie, and Cart (1) with cart=mint.
Check your understanding
For each question, ask what exists during the build, and what only a request brings.
Question 1 of 7What does
cookies()give you in a Server Component?Choose an answer to see the explanation.
Question 2 of 7What happens when you build the tea shop with this home page?
Read the code, then predictTSXimport { 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.
Question 3 of 7Why do
cookies(),headers(),paramsandsearchParamsreturn promises?Choose an answer to see the explanation.
Question 4 of 7What does
await connection()do?Choose an answer to see the explanation.
Question 5 of 7The shop's news page reads
draftMode()at the top, with noSuspense. What does the build print for it?Choose an answer to see the explanation.
Question 6 of 7Where can you set a cookie in the App Router?
Choose an answer to see the explanation.
Question 7 of 7This is
app/api/language/route.ts. What does the build print for it?Read the code, then predictTypeScriptimport { 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()andheaders()read the request;paramsandsearchParamsread 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
Suspenseare◐; Route Handlers that read it areƒ; a page that only readsdraftMode()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.