How a Server Component reads per-request data with the Next.js cookies() and headers() functions.
Picture the Server Component that renders your dashboard. To do its job it needs four values specific to this request: the user’s session token to know who they are, their locale to render in the right language, the request’s IP address so a downstream limiter can throttle abuse, and the User-Agent string so analytics can record their device.
None of those values live in your code; they arrive with the request. The App Router exposes them through two functions imported from next/headers: cookies() and headers(). The session token and locale ride in cookies; the IP and User-Agent ride in headers. Together, those two reads cover almost everything dynamic a Server Component renders for a specific user.
The previous chapter introduced their syntax: both return Promises, so you await them. This lesson is about using them well: where to read, what each read costs, and what you can trust.
One idea underpins the rest: a route’s only inputs are the URL, the headers, and the cookies. When a route renders something different for one user than another, that difference arrived through one of those three channels. This lesson covers two of them.
URL
Headers
Cookies
Server Componentrenders for this user
A route’s only inputs are the URL, headers, and cookies. This lesson covers headers and cookies.
Each function answers one question: how do I read this kind of value off the request?
await cookies() hands you the request’s cookie store. Read from it with get(name), which returns a { name, value } object, or undefined if that cookie isn’t set. There’s also getAll() for every cookie at once and has(name) for a presence check.
import { cookies } from'next/headers';
const cookieStore = await cookies();
const theme = cookieStore.get('theme')?.value;
await headers() hands you a read-only Headers instance: the same web-platform Headers object you already know, with get, has, entries, and the rest. It’s scoped to this request, and you can’t write to it.
You’ve awaited these before, so the only new part is the return shape: the cookie get returns an object you unwrap with .value, while the header get returns a plain string.
Two facts about both stores. First, they’re scoped to the current request and thrown away after the render: not global, not shared between users, not carried over from the last request. Second, the cookie store has set and delete methods, but calling them from a Server Component render is the wrong context. The next section explains why.
Both functions are server-only. Import cookies or headers into a Client Component and you get a build error. The request object lives on the server, so this follows from the server/client boundary you already know: the browser never sees next/headers, so there is nothing there for it to read.
From a Server Component the cookie store is also read-only. You can read a cookie but not set one, and to see why you need an accurate picture of what a cookie is.
A cookie is client-side storage. The server never holds your cookies. To store one, it adds a Set-Cookie instruction to the response headers; the browser reads that instruction and writes the cookie into its own jar, then sends it back on the next request. The server only ever reads what the browser sends and asks it to store more.
That detail is the whole story. A Set-Cookie lives in the response headers, and HTTP forbids changing headers once the response body has started streaming. The App Router streams HTML as it renders, so by the time a Server Component runs, the headers are already on the wire and there is nowhere left to attach a Set-Cookie. Calling set here is not supported during rendering: Next.js flags it rather than silently writing nothing.
const cookieStore = await cookies();
cookieStore.set('theme', 'dark'); // not supported during render: the response already started
A write therefore needs a context where the response hasn’t started and the headers are still open. There are two: a route handler , and a Server Action, the write path you’ll meet in the forms unit. The shape to remember: reads happen during render, writes happen in an action.
The core habit for working with request data: read cookies() and headers() high in the tree, at the layout or page, derive the values you care about there, and pass those down as props. Don’t thread the raw cookie store through your components or re-read it in a leaf five levels deep.
Two reasons, reinforcing each other.
The first is readability. With every await clustered at the top of one file, the rest of the tree stays synchronous and plain: easy to read, test, and move. A leaf that takes a locale prop holds no surprises; one that suddenly calls await cookies() does.
The second is the cost of derivation. Turning a session cookie into the current user is a database lookup, not a free read. Do it once near the top and reuse the result, rather than redoing it in every component that needs the user.
One caution, so you don’t over-correct: re-reading the store is cheap, since the read is per-request and costs almost nothing, so don’t contort your code to avoid it. What you’re avoiding is redoing expensive derivation. When a derived value like the current user is needed all over the tree and prop-drilling it everywhere hurts, wrap the derivation in React’s cache() from the previous chapter, and every caller in the same request shares one computation. cache() earns its place when there’s costly work behind the read, not on every trivial cookieStore.get.
Reads cluster at the top; children stay simple. The layout does the one await, derives locale, and hands it down as a plain prop. LocaleBadge is synchronous and has no idea cookies exist.
exportdefaultasyncfunctionDashboardLayout({ children }) {
Works, but scatters request reads through the tree. Every leaf that needs a request value becomes async and reaches for the store itself, spreading the request surface across the tree instead of one visible place.
Note the export default on the layout: layout.tsx is one of the few files where the framework requires a default export.
This is what a production app pulls off the request. You’re not memorizing it; you’re building enough vocabulary to recognize each read when you meet it, so accept-language in a layout isn’t a mystery.
Cookies
Session cookie: identifies the signed-in user. The authentication unit wires it up; here we just name it.
CSRF cookie: managed for you by the auth library.
Locale preference: the user’s chosen language, once they pick one.
Feature-flag / A/B-test cookies: which experiment bucket this visitor is in.
By convention this course’s session cookie carries the __Host- prefix and the flags HttpOnly; Secure; SameSite=Lax. HttpOnly matters most: client JavaScript can’t read the cookie, so a script injected through an XSS hole can’t steal the session.
Headers
x-forwarded-for: the client’s IP, as reported by the proxy in front of your app. Read it carefully; see the next section.
user-agent: the browser/device string, for analytics.
accept-language: the browser’s language preferences, a fallback when there’s no locale cookie.
referer: where the user navigated from, for analytics.
Two of these, the session cookie and x-forwarded-for, carry trust implications. The next section takes on the one with the highest stakes.
Headers like x-forwarded-for exist because your app sits behind a proxy: Vercel, Cloudflare, a load balancer. The client connects to the proxy, the proxy connects to your app, so your app would otherwise only see the proxy’s IP. To preserve the original, the proxy writes the client’s address into x-forwarded-for.
But that header is just a string in the request, and anyone can put anything in it. A client can open a raw connection and send x-forwarded-for: 1.2.3.4, or x-user-role: admin, or any header they like. Treat headers as attacker-controlled by default. x-forwarded-for is trustworthy only because a proxy you control overwrites it with the real value before it reaches you.
So the rule has two halves:
Trust a proxy header only when you’re behind a known proxy and read the value that platform documents. On Vercel that’s x-forwarded-for or x-real-ip, which Vercel sets while stripping any client-supplied version. Anywhere else, you’re trusting a forgeable string.
Never make an identity or authorization decision from a raw header. “Who is this user, and what may they do?” is answered by the session, the cookie-backed identity your server verifies, never by a header the client could have typed.
The line to hold: headers are for telemetry and platform-provided signals, the session is for identity and permission. The client IP you read here is for recognition only, such as analytics and logging. Throttling abusive callers with it is rate-limiting, which has its own chapter later.
You have the Cache Components pieces from the last chapter. Here’s how a request read interacts with them, in the order that usually trips people up.
Reading cookies() or headers() is an explicit dynamic signal. The framework’s static analysis sees the read and marks that path dynamic: it can’t be prerendered, because its output depends on a request that doesn’t exist at build time.
That costs you almost nothing. Every route is already dynamic by default, so the read doesn’t make the route “more dynamic” than a component that reads nothing; it just keeps that one subtree from being made static, and you weren’t getting that subtree for free anyway.
The one hard rule: a cookies() or headers() read inside a use cache function is a build error. A cached function has to be request-independent, because its job is to produce a result reusable across requests and users, and a value that differs on every request can’t live there. The error names itself: Cannot access cookies() or headers() in a use cache scope.
The fix is also the pattern: read the request value in an uncached parent, then pass it into the cached function as an argument. The argument joins the cache key, so each distinct value gets its own entry and the cached work stays shared, just keyed by the input. Keep the request read in the dynamic part of the tree, and lift your cached chrome (the header, sidebar, and footer, the parts that look the same for everyone) out of that subtree so they can ship in the shell.
The trace below shows the split: a cached static header in the shell, and a user greeting that reads the session cookie and so streams in later as a dynamic hole.
Where the cookie read sits
Server
Network
Browser
GET/dashboard
Props crossing the wire
The static shell paints first. AppHeader is the same for every visitor, so it ships as prerendered chrome.
UserGreeting reads the session cookie, so it can’t be static. It streams in as a hole once the read resolves. Keep request reads down here; keep cached chrome up in the shell.
Test the reasoning with this question.
A page wraps getCatalog() in use cache so the product list is shared across users. Inside getCatalog() you add await cookies() to read the visitor’s currency preference. What happens, and what’s the right fix?
The build fails. Pull the await cookies() read up into the uncached caller and hand the currency to getCatalog(currency), so the value reaches the cached work as a keyed input rather than a hidden request read.
It builds and runs, but the cookie read forces getCatalog() to re-execute on every call instead of serving the shared entry.
It builds and runs; the read just resolves to a default currency because a cached scope has no live request to read from.
It builds and runs; only the catalog component flips to dynamic at request time while the rest of the page stays cached.
Reading cookies() inside use cache is a build error — a cached scope has to be the same for every request, and a per-visitor cookie breaks that promise. Lifting the read into the caller and passing currency in as an argument folds it into the cache key, so each currency gets its own entry while the expensive catalog work is still shared.
A Client Component can’t call cookies() or headers(): they’re server-only, so it’s a build error. The client can read very little of the request, and rarely the part you want.
The browser can read document.cookie, but only for cookies that aren’t HttpOnly, which excludes the session by design. Request headers aren’t readable at all; by the time the client runs, the request is long over.
So the rule is the one from earlier, now stated for the boundary: read on the server, and pass resolved values down as props, or through context for cross-cutting values like the current locale. When you catch yourself reaching for document.cookie, it usually means the data should have come down from the server render.
// Client Component — rare, and usually a sign of a missed server read
Sort the items below to check you’ve got all the rules at once: server read, action-only write, the trust boundary, and the rare but acceptable client read.
Each item is something an app needs from a request. Sort it by where it can correctly be read or done.
Drag each item into the bucket it belongs to, then press Check.
Read it on the servercookies() / headers() during render
Can't / shouldn't read it therewrong context, or untrusted
The session token, to identify the user
User-Agent, for analytics
The visitor’s locale from Accept-Language
Set a theme cookie from a Server Component
Decide if the user is an admin from an x-user-role header
Call cookies() inside a Client Component
Worked example: reading session and locale at the root layout
Here is the shape you’ll ship: a root layout that reads the request once, derives what it needs, and renders the shell.
One helper is a deliberate black box. getCurrentUser() resolves the session cookie through the auth library and returns the current user, wrapped in React’s cache() so it runs at most once per request however many components call it. You’ll build it in the authentication unit; here you can lean on it.
exportdefaultasyncfunctionRootLayout({ children }: { children:ReactNode }) {
Both request reads sit here at the top and run once: the cookie store and the headers, each behind an await. Nothing below touches the request directly.
exportdefaultasyncfunctionRootLayout({ children }: { children:ReactNode }) {
Derive the locale by precedence: the explicit locale cookie wins; otherwise the browser’s first Accept-Language preference; otherwise English. One resolved value, computed once.
exportdefaultasyncfunctionRootLayout({ children }: { children:ReactNode }) {
The session read hides behind the cached helper, so the layout never sees the raw token. Because getCurrentUser is cached per request, children call it themselves and share this one lookup instead of receiving user as a drilled prop.
exportdefaultasyncfunctionRootLayout({ children }: { children:ReactNode }) {
Render the shell. locale flows down as a plain prop, and which shell renders depends on whether a user is signed in.
1 / 1
Two values leave this layout on different channels: user through the per-request cache, so any component deeper in the tree calls getCurrentUser() and gets the same answer for free, and locale, a cheap derived string, as a plain prop.
Every cookie and header you set rides on every matched request and response, there and back, for the whole session. That’s cheap when a cookie holds a short ID. It gets expensive when it holds a payload: a fat JWT with the user’s whole profile, a session blob with their permissions and preferences inline, a kitchen-sink cookie someone kept appending to. A bloated cookie adds latency to every round-trip, and it can push you past the header-size limits servers and CDNs enforce, where requests start failing in ways that are hard to trace.
So keep cookies small and set HttpOnly and Secure by default (the __Host- convention bundles those in). Store a reference, and look up the record server-side.
The next lesson turns to the proxy itself, which runs on every request the matcher catches: another cost paid on every byte of every request.
These are the canonical references for the two functions, the error you’ll see if you read the request inside a cached scope, and the mental model behind the whole lesson: that a cookie is client-side storage the server only instructs and reads.