How an App Router Server Component reads per-request data with Next.js cookies() and headers().
Picture the Server Component that renders your dashboard. To do its job it needs four things that are specific to this request, from this user, right now: their session token, so it knows who they are; their preferred locale, so it renders 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 what they’re browsing on.
None of those four values live in your code. They arrive with the request. So the question that motivates this lesson is: in the App Router, where does a Server Component reach to get them?
The answer is two functions, both imported from next/headers: cookies() and headers(). The session token and the locale preference ride in cookies, and the IP and the User-Agent ride in headers. Between them, those two reads cover almost everything dynamic a Server Component renders for a specific user.
You met these two functions in the previous chapter, where the point was the syntax: they return Promises, so you await them. This lesson is about using them well: where to read, what each read costs, what you can trust, and the one pattern that keeps the rest of your component tree clean. By the end you’ll be able to read any request value on the server and know exactly what that read does to the rest of your app.
One idea underpins everything that follows: a route’s inputs are the URL, the headers, and the cookies, and nothing else. There is no hidden fourth channel. When a route renders something different for one user than for another, that difference arrived through one of those three doors. This lesson covers two of them.
URL
Headers
Cookies
Server Componentrenders for this user
Every difference a route renders for one user versus another arrives through one of three channels. This lesson covers two of them: headers and cookies.
Each function answers one question: how do I read this kind of value off the request? Here is each one in turn.
await cookies() hands you the request’s cookie store. From it you read 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 quick 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. The only difference is that it’s scoped to this request, and you can’t write to it.
That’s the whole API surface for reading. You’ve awaited these before, so the new part is just this: the cookie get returns an object you unwrap with .value, while the header get returns a plain string.
Two facts about both stores are worth settling now. 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 object does have set and delete methods, but in a Server Component you can’t use them. The object isn’t missing those methods; calling them from a render is simply the wrong context. The next section explains why.
Two constraints govern these reads. Both have a reason, and once you see the reason they stop feeling arbitrary.
The first is that both functions are server-only. Import cookies or headers into a Client Component and you get a build error, not a runtime surprise. This follows from the server/client boundary you already have: the request object lives on the server. The browser never sees next/headers, so there’s nothing there for it to read. The read lives where the request lives.
The second is more subtle: from a Server Component, these stores are read-only. You can read a cookie, but you can’t set one. To see why, you need the right mental model of what a cookie even is, and it’s not what most people assume.
A cookie is client-side storage. The server never holds your cookies. When a server wants the browser to store a cookie, it doesn’t store anything itself: it adds a Set-Cookie instruction to the response headers, and the browser reads that instruction and writes the cookie into its own jar. On the next request the browser sends those cookies back up. The server only ever reads what the browser sends, and asks the browser to store more.
That instruction-in-the-response detail is the whole story. A Set-Cookie instruction lives in the response headers, and HTTP has a firm rule about those: you cannot set headers once the response body has started streaming. By the time a Server Component renders, the response has already begun streaming to the browser. You learned in the previous chapter that the App Router streams HTML as it renders, so by the time your component runs, the headers are already out over the wire. There is no longer a place to attach a Set-Cookie. That is why setting a cookie is not supported during rendering: call set here and 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
Writing a cookie, then, needs a context where the response hasn’t started yet, somewhere the headers are still being composed. There are two such places: a route handler , and a Server Action, the write path you’ll meet in the forms unit. For now, the shape to remember is simple: reads happen during render, and writes happen in an action. You don’t need to write one yet.
This client-storage model earns its keep twice over. It also explains why the client-side cookie API looks nothing like the server one, which is a point we return to later.
This section covers the single most important habit for working with request data, and the rest of this chapter assumes you follow it.
The habit is this: read cookies() and headers() high in the tree, at the layout or the page; derive the values you actually care about there; and pass those resolved values down as props. Don’t thread the raw cookie store through your component tree, and don’t re-read it in some leaf component buried five levels deep.
There are two reasons, and they reinforce each other.
The first is readability. When all your await calls cluster at the top of one file, the rest of the tree stays synchronous and plain: easy to read, easy to test, easy to move around. A leaf component that suddenly does await cookies() is a small surprise every time someone reads it, whereas a leaf that just takes a locale prop has no surprises at all.
The second is that derivation is often real work. Turning a session cookie into “the current user” isn’t free; in a real app it’s a database lookup. You want to do that once, near the top, and reuse the result, rather than redoing it in every component that happens to need the user.
One clarification, because it’s easy to over-correct here. Re-reading the cookie store in a few places is genuinely cheap: the read is per-request and costs almost nothing, so don’t contort your code to avoid it. What you’re really avoiding is redoing expensive derivation. When a derived value like the current user is needed all over the tree, and prop-drilling it everywhere is painful, the tool is React’s cache() from the previous chapter. Wrap the derivation, and every caller in the same request shares one computation. So cache() earns its place when there’s costly work behind the read, not as a wrapper you reach for on every trivial cookieStore.get.
The contrast below shows the habit and its opposite.
Reads cluster at the top, and children stay simple. The layout does the one await, derives locale, and hands it down as a plain prop. LocaleBadge is synchronous and trivial to read, test, and reuse, and it has no idea cookies exist.
exportdefaultasyncfunctionDashboardLayout({ children }) {
Works, but scatters request reads through the tree. Every leaf that needs a request value is now async and reaches for the store itself. Nothing is broken, but the request surface is spread across the whole tree instead of read once in a place you can see.
Note the export default on the layout: layout.tsx is one of the few files where the framework requires a default export. You’ll see why when this lesson’s worked example puts it all together.
The SaaS reference list: which cookies, which headers
Here is what a production SaaS actually pulls off the request, so you can recognize the real reads when you meet them. You’re not memorizing this list; you’re building a vocabulary, so that when you see accept-language in a layout you know what it’s for.
Cookies
Session cookie: the auth layer. Identifies the signed-in user. Wired up in the authentication unit; here we just name it.
CSRF cookie: managed for you by the auth library. Named, not configured by hand.
Locale preference: the user’s chosen language, when they’ve picked 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 is the one that matters most for safety: it means client JavaScript can’t read the cookie at all, so a script injected through an XSS hole can’t steal the session.
Headers
x-forwarded-for: the client’s IP address, 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 navigation analytics.
Two of these reads, the session cookie and x-forwarded-for, carry trust implications, and the next section covers the one with the highest stakes.
This is the one mistake in the lesson that turns into a real vulnerability in production, not just a style nit, so it’s worth slowing down for.
Headers like x-forwarded-for exist because your app usually sits behind a proxy: Vercel, Cloudflare, a load balancer. The real client connects to the proxy, and the proxy connects to your app, so your app would otherwise only ever 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. The only reason x-forwarded-for is ever trustworthy is that a trusted 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 you read the value that platform documents. On Vercel, that’s x-forwarded-for or x-real-ip, which Vercel sets while stripping client-supplied versions. Off-platform, or reading some arbitrary header a client could have invented, you’re trusting a forgeable string.
Never make an identity or authorization decision from a raw header. “Who is this user and what are they allowed to 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 keep is this: headers are for telemetry and platform-provided signals, and the session is for identity and permission. The client IP you read here is for recognition: analytics, logging, knowing where traffic comes from. Using it to actually throttle abusive callers is rate-limiting, which has its own chapter much later. The read is the same; the security machinery comes then.
You just finished the chapter on the Cache Components rendering model, so you have the pieces. Here’s the precise interaction, walked through in the order that usually causes confusion.
First: reading cookies() or headers() is an explicit dynamic signal. The framework’s static analysis sees that read and marks the code path as dynamic. It cannot be prerendered, because its output depends on a request that doesn’t exist yet at build time.
Second, and this is the part that surprises people, that costs you almost nothing. Every route is already dynamic by default. So compared with a component that reads nothing, the cookie read doesn’t make the route “more dynamic”; it just prevents that one subtree from being made static. You weren’t getting that subtree for free anyway.
Third, the one hard rule: putting a cookies() or headers() read inside a use cache function is a build error. A cached function has to be request-independent. Its whole job is to produce a result that can be reused across requests and across users, so a value that differs on every request can’t live inside it. You’ll see the error named explicitly: Cannot access cookies() or headers() in a use cache scope. It’s worth recognizing, because it’s the most common Cache Components mistake involving request reads.
Fourth, the fix, which is also the pattern: read the request value in an uncached parent, then pass it into the cached function as an argument. The argument becomes part of the cache key, so each distinct value gets its own cache entry, and the cached work is still shared, just keyed by the input. Keep the cookie read in the dynamic part of the tree, and lift your cached chrome (the header, the sidebar, the footer, the parts that look the same for everyone) out of that subtree, so they can be served from the shell.
The trace below shows that split: a cached static header that ships 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’s part of the 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, and 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.
The client side: you don’t read the request, you receive it
By now you’d expect a Client Component to be unable to call cookies() or headers(): they’re server-only, a build error, same as before. So what can the client do? Only a few narrow things, and rarely the ones you want.
The browser can read document.cookie, but only for cookies that aren’t HttpOnly, which means never the session, by design. That HttpOnly flag exists precisely to keep client JavaScript away from it. Headers are visible only by inspecting the Response object that comes back from a fetch call; there’s no general “read this request’s headers” on the client, because by the time the client is running, the request is long over.
The pattern is the same one from earlier, now stated as a rule for the boundary: read on the server, and pass resolved values down as props, or through context for genuinely cross-cutting values like the current locale. Reaching for document.cookie is rare, and when you catch yourself doing it, it’s usually a sign the data should have come down from the server render in the first place.
// 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
Now put the pieces together into the shape you’ll actually ship, the one the rest of this chapter and the authentication unit build on. It’s a root layout that reads the request once, derives what it needs, and renders the shell.
One helper in the code is a deliberate black box. getCurrentUser() is imported: internally it resolves the session cookie through the auth library and returns the current user, and it’s wrapped in React’s cache() so it runs at most once per request, no matter how many components call it. You’ll build it in the authentication unit, so here you can lean on it. That’s also why the layout never touches the raw session cookie itself: the production habit is that session reads always go through getCurrentUser, never a hand-rolled cookie read.
exportdefaultasyncfunctionRootLayout({ children }: { children:ReactNode }) {
Every request read for this layout lives here, at the top, and runs once. There are two awaits, the cookie store and the headers, and nothing below this point touches the request directly.
exportdefaultasyncfunctionRootLayout({ children }: { children:ReactNode }) {
Derive the locale with a clear precedence: the explicit locale cookie wins; if there isn’t one, fall back to the browser’s first Accept-Language preference; if neither exists, default to English. The result is a single 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 React-cached, children can call it themselves and share this one per-request lookup, rather than receiving user as a drilled prop.
exportdefaultasyncfunctionRootLayout({ children }: { children:ReactNode }) {
Render the shell. locale flows down as a plain prop (or via context), and which shell renders depends on whether there’s a signed-in user. Reads sit at the top and resolved values flow below, which is the whole pattern in one file.
1 / 1
Notice what the layout doesn’t do: it doesn’t prop-drill user into every child. Because getCurrentUser() is cached per request, any component deeper in the tree can just call it again and get the same answer for free. That’s the cache()-behind-a-derivation pattern from earlier, paying off exactly where it should. The locale, being a cheap derived string, goes down as a prop instead. Two different values, each on the channel that suits it.
One last practical point, and it’s a cost worth keeping in mind.
Every cookie and every header you set rides on every matched request and every response, there and back, for the whole session. That’s cheap when a cookie holds a short ID. It gets expensive fast when it holds a payload: a fat JWT with the user’s whole profile baked in, a session blob with their permissions and preferences inline, a kitchen-sink cookie someone kept appending to. A bloated cookie doesn’t just add latency to every round-trip. It can also push you past the header-size limits that servers and CDNs enforce, and then requests start failing in ways that are hard to trace back to the cause.
The discipline is simple: keep cookies small, keep the session lean, and set HttpOnly and Secure by default (the __Host- convention from earlier bundles those in). Store a reference, and look up the record server-side.
This is the through-line of the whole chapter: every byte on the request has a cost, and the habit worth building is knowing what you’re paying for. You’ll meet the same theme from a different angle in the next lesson, where the cost is the proxy running on every request the matcher catches.
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.