Lesson 95·M10 App Router
Cookies, Headers & Metadata
OpenLearning Objectives
By the end of this lesson you should be able to:
- Read cookies and headers in Server Components and Route Handlers with
cookies()/headers() - Explain why both are async in Next.js 15 and what that did to the API
- Use
generateMetadatafor dynamic, per-route metadata - Set up a
titletemplate in the root layout and say what%s,defaultandabsolutedo - Say why reading request data makes a route dynamic
What Are These APIs?
cookies() and headers() are the read-only, request-scoped views of the HTTP request available in Server Components and Route Handlers; the metadata API is how a route declares its <head> output.
Two different jobs in one lesson: the first two are input (what the browser sent us), the
metadata API is output (what we send back in the head). They meet in generateMetadata,
which can read cookies and headers while computing metadata.
Mental Model
cookies()/headers()— the waiter's notepad. Every request, the server writes down what the browser sent: the session cookie, the locale, the user agent. Your Server Component picks up the notepad for that one request and reads from it. It's read-only — you don't edit the notepad, you jot the bill on the response.- The metadata API — the shop sign. The layout declares the template ("All Store | "), each page declares its own title, and the sign out front reads "All Store | About".
Visual Flow
Browser request
│ (cookies + headers arrive with the request)
▼
┌───────────────────────────────┐
│ Server Component / Route │
│ const c = await cookies() │ ← reads request context
│ const h = await headers() │
└───────────────┬───────────────┘
│
▼
┌───────────────────────────────┐
│ generateMetadata (same request)│ ← resolves <title>, description, OG
└───────────────┬───────────────┘
│
▼
prerendered at build time ── static (no request data read)
│
rendered per request ── dynamic (cookies/headers/params read)
│
▼
HTML with <head> filled inHow It Works
cookies() and headers() come from next/headers. They are async and request-scoped:
they must be awaited (Next.js 15+), and they can only be called while a request is being
handled — a Server Component render, a Route Handler, or a Server Action.
// app/layout.tsx — a Server Component by default
import { cookies, headers } from 'next/headers';
export default async function RootLayout() {
const cookieStore = await cookies(); // awaited — required in Next 15
const theme = cookieStore.get('theme')?.value ?? 'light';
const headerList = await headers();
const ua = headerList.get('user-agent');
return (
<html lang="en">
<body data-theme={theme}>
{/* … */}
</body>
</html>
);
}
request with Cookie: theme=dark; user-agent: Mozilla/5.0 …
→ <html lang="en"><body data-theme="dark">
Why async? Because the underlying Web APIs (RequestCookies, Headers) are dynamic, and
the static-analysis engine needed a Promise boundary it could trace — sync usage was
ambiguous during prerendering. The practical rule: await them, and the TypeScript error
'cookies' should be awaited disappears.
Pitfall
The error "Route … used cookies()" means Next.js detected request data during a build-time
prerender. Reading cookies/headers makes the route dynamic — it can no longer be a fully
static page (Lesson 91). That's by design: the value only exists at request time.
The same functions work in a Route Handler (Lesson 92):
// app/api/me/route.ts
import { NextResponse } from 'next/server';
import { cookies, headers } from 'next/headers';
export async function GET() {
const token = (await cookies()).get('session')?.value;
const userAgent = (await headers()).get('user-agent');
return NextResponse.json({ hasSession: Boolean(token), userAgent });
}
curl -H 'Cookie: session=abc123' http://localhost:3000/api/me
→ {"hasSession":true,"userAgent":"curl/8.5.0"}Real Project Usage
| Feature | The cookie/header you read | Where |
|---|---|---|
| Dark mode | theme cookie → <body data-theme> | Root layout |
| i18n | accept-language header, or a locale cookie | Layout + generateMetadata |
| Auth (read side) | session cookie → gate or greet the user | Layout, pages, Route Handlers |
| Analytics | user-agent → device breakdown | Route Handlers |
| A/B testing | bucket cookie → choose the variant | Middleware (Lesson 94) or layout |
The layout + generateMetadata combo on the same page:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
export const metadata: Metadata = {
title: 'Blog', // augmented by the root template
};
export async function generateMetadata(): Promise<Metadata> {
const theme = (await cookies()).get('theme')?.value;
return {
description: `Blog — shown to ${theme} theme users`, // dynamic, request-time
};
}Interview Explanation
cookies()andheaders()give a Server Component or Route Handler read access to the current request's cookies and headers. They're async — youawaitthem — and they make the route dynamic, because the values only exist at request time. Setting cookies happens in Server Actions or Route Handlers, not during render.Metadata is the parallel system for the
<head>: staticexport const metadatain a layout or page, dynamicgenerateMetadatawhen the title depends on params or request data, and a root-leveltitle.templatelike%s | Acmethat every page title flows through.
Senior-Level Insights
awaitis the whole game. The Next 15 async migration changed every codebase at once. Knowing why — request context is dynamic and the compiler needed an awaitable boundary — beats memorising the codemod.- Read-only during render is a rule, not a suggestion. Setting cookies in a Server
Component render is illegal. The write path is a Server Action (Lesson 93), a Route
Handler, or
NextResponse.cookies.setin middleware. - Request data makes routes dynamic — and that has a cache cost. A page that reads
cookies can't be a static file; it renders per request. That's correct, but it's why
Lesson 91's static/dynamic trade-offs exist.
generateMetadatareading a cookie does the same to the metadata path. - Metadata merges shallowly. Child segments override whole nested fields (all of
openGraph, not just one key) unless you spread the parent's values back in. title.templateonly applies to child segments. The segment that defines the template doesn't use it on its own title,defaultis required alongside it, andabsoluteis the escape hatch.
Common Mistakes
❌ Forgetting await in Next.js 15:
const token = cookies().get('session'); // 💥 TypeError: cookies is not a function …
const token = (await cookies()).get('session'); // ✅
❌ Setting a cookie during render:
const c = await cookies();
c.set('theme', 'dark'); // 💥 "Cookies can only be modified in a Server Action or Route Handler"
❌ Breaking the template — a page title that must ignore %s:
title: { absolute: 'Acme' } // ✅ ignores the parent template
❌ Defining title.template with no default — the build errors out.
❌ Declaring both metadata and generateMetadata in the same segment — not allowed;
pick one per route.
Best Practices
✅ await cookies() / await headers() — every time
✅ Read cookies/headers in Server Components, set them in Actions / Route Handlers / middleware
✅ Keep request-data reads in the layout or page that needs them — don't thread them through client components
✅ Put the title.template + default in the root layout
✅ Use generateMetadata only when metadata depends on params, searchParams, or request data — otherwise the static metadata object is cheaper and prerenderable
❌ Don't read cookies just to pass the value to a client component — that forces the whole page dynamic
Interview Questions
Q1. How do you read a cookie in a Server Component?
Import
cookiesfromnext/headersand await it:(await cookies()).get('theme')?.value. It's async in Next.js 15 because request context is dynamic. Reading it opts the route out of static prerendering.
Q2. Why did cookies() become async?
So the request context is represented by a
Promisethe compiler can trace — sync usage was ambiguous during prerendering, where no request exists. The migration codemod rewrote every call toawait, andNEXT_STATIC_GEN_BAILOUTwas added to explain the "Route used cookies()" error.
Q3. Where are you allowed to set cookies?
In a Server Action, a Route Handler, or middleware via
NextResponse.cookies.set. Never in a Server Component render — the request has already arrived; the response hasn't been written yet. Reading is render-safe; writing is response-time work.
Q4. What does generateMetadata do?
It's the async, per-route way to produce
Metadata— for pages whose title or description depends onparams,searchParams, or request data like cookies. Next.js resolves it during render and writes the tags into the initial HTML, so crawlers see them without JavaScript.
Q5. How does a title template work?
In the root layout:
title: { template: '%s | Acme', default: 'Acme' }. Every child page that sets a plaintitlegets%sreplaced and the template applied —AboutbecomesAbout | Acme.defaultis required with a template,absolutebypasses it, and the template only applies to child segments, not the segment that defines it.
Senior follow-up: How does metadata merging work across nested layouts?
Metadata is evaluated from the root segment down to the page and shallowly merged — a child's string fields override the parent's, and for nested objects like
openGraphthe child's whole object replaces the parent's. If a page wants to keep the parent's OG image while changing the title, it must spread the parent's fields back in.generateMetadataalso receives the resolved parent metadata as its second argument, so it can extend rather than clobber.
Follow-up Questions
Do cookies() and headers() work in middleware too?
Middleware gets the request directly —
request.cookiesandrequest.headerson theNextRequest, no import needed. Same data, earlier in the pipeline (Lesson 94).
How do you read search params from the metadata API?
generateMetadatareceivesparamsandsearchParamsas its first argument, same shape as the page component. Both arePromises in Next.js 15.
What makes a page dynamic, concretely?
Reading
cookies(),headers(),params, orsearchParams, or callingfetch(..., { cache: 'no-store' }). Any of those means the output depends on the request, so Next.js renders it per request instead of emitting a static file.
Comparison Table
cookies() / headers() | metadata object | generateMetadata | |
|---|---|---|---|
| Direction | Input — reads the request | Output — declares <head> | Output, computed per route |
| When | Request time (dynamic) | Build time (static) | Request or build, per page |
| Where | Server Components, Route Handlers | layout.tsx / page.tsx | layout.tsx / page.tsx |
| Needs data | always | never | when title/description depend on data |
| Effect on route | makes it dynamic | none | dynamic if it reads request data |
Code Example
A root layout with a theme cookie and a title template, plus a page that resolves its own metadata from its params:
// app/layout.tsx
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
export const metadata: Metadata = {
title: { template: '%s | Acme', default: 'Acme' },
description: 'Acme storefront',
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const theme = (await cookies()).get('theme')?.value ?? 'light';
return (
<html lang="en" data-theme={theme}>
<body>{children}</body>
</html>
);
}
// app/products/[id]/page.tsx
import type { Metadata } from 'next';
type Props = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const product = await fetch(`https://api.acme.com/products/${id}`).then((r) => r.json());
return { title: product.name, description: product.tagline };
}
export default async function Page({ params }: Props) {
const { id } = await params;
// …render the product…
}
Resulting <head> for /products/42 when the product is "Gizmo":
<html lang="en" data-theme="dark">
<head>
<title>Gizmo | Acme</title>
<meta name="description" content="The gizmo that does everything" />
</head>
…
</html>Performance Notes
- Reading cookies/headers makes a route dynamic — it can't be a static file, so it renders per request. Use it only where the value genuinely differs per user/request.
generateMetadatais free to skip. If a page's metadata is static, export the plainmetadataobject; it's resolved at build time and never blocks a request.- Fetch inside
generateMetadatais memoized for the same data acrossgenerateMetadata, layouts, pages, and Server Components in one render pass — one network call, not several. - Streaming metadata (Next 15.2+) lets the initial UI render before slow
generateMetadatafinishes; HTML-only crawlers still get the full head.
Debugging Scenarios
| Symptom | Cause | Fix |
|---|---|---|
'cookies' should be awaited | Next 15 async API used without await | const c = await cookies() |
"Route used cookies() …" during build | Request data read during prerender | Accept the dynamic route, or restructure so the read isn't needed |
Cookie is undefined in a Server Component | Read at build time, or set after render began | Ensure it's set before the request arrives (login action), read at request time |
<title> shows the raw page title, template ignored | Template defined in the same segment that renders, or no default | Move template+default to the root layout; default is required |
| Metadata "missing" on social share | openGraph fields overridden by a child's shallow merge | Spread parent fields back in generateMetadata |
Quick Revision Notes
await cookies()/await headers()— async since Next.js 15, Server Components + Route Handlers only- Reading request data ⇒ dynamic route (can't prerender to static)
- Set cookies in Server Actions / Route Handlers / middleware — never during render
- Static
export const metadatafor fixed<head>;generateMetadatawhen it depends on data - Root layout:
title: { template: '%s | Acme', default: 'Acme' } absoluteignores the template;defaultis required with a template- Metadata merges shallowly — child
openGraphreplaces parentopenGraph - Fetch in
generateMetadatais memoized within the render pass
Cheat Sheet
import { cookies, headers } from 'next/headers';
const c = await cookies(); c.get('theme')?.value
const h = await headers(); h.get('user-agent')
// static metadata (build time):
export const metadata: Metadata = {
title: { template: '%s | Acme', default: 'Acme' },
description: '…',
};
// dynamic metadata (request time):
export async function generateMetadata({ params }: Props) {
return { title: (await product).name };
}
// set cookies — only in a Server Action / Route Handler / middleware:
(await cookies()).set('theme', 'dark'); // ✅ Action/Handler
NextResponse.cookies.set('theme', 'dark'); // ✅ middlewareKey Takeaways
Recap
cookies()andheaders()are async, read-only request context — Server Components and Route Handlers only- Reading them makes a route dynamic; that's why the "Route used cookies()" error exists
- Writes happen in Server Actions, Route Handlers, or middleware — never during render
- Static
metadatafor fixed head tags;generateMetadatawhen the head depends on data title.template(%s | Acme) +defaultlives in the root layout;absoluteescapes it- Metadata is shallow-merged down the segment tree — children replace whole nested fields
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/7Why must cookies() and headers() be awaited in Next.js 15?
Where are you allowed to set a cookie, and why not in a Server Component render?
What happens to a route when it reads a cookie — and why?
export const metadata vs generateMetadata — when do you choose which?
Given a root title: { template: '%s | Acme', default: 'Acme' }, what is the final title for a page exporting title: 'About'?
What does title.absolute do, and why does title.template need title.default?
How does metadata merge when a child page defines openGraph?
What's Next
Lesson 96 — Env Vars, Build & Deployment. What NEXT_PUBLIC_ actually does to your
bundle, the .env files Next.js reads, the build pipeline (build/start, static vs
dynamic), and shipping on Vercel.