skip to content

In the Next.js App Router, how does a Server Action set an HTTP cookie on the response, and why does the same code throw if you put it in a Server Component's render?

level: juniorimportance: should knowfreq 38%

answer

  1. next/headers, awaited
  2. set, get, delete on the store
  3. cookies are response headers
  4. render may already be streaming
  5. a write context, not a read context

basics

~20 s

Await cookies() from 'next/headers' inside the action and call .set(name, value, options) on the returned store; .delete(name) removes one. Server Component renders can only read cookies, because their response headers may already be streaming, so writing there throws.

solid answer

~40 s

Inside the action you `await cookies()` from `next/headers` and call `.set()` on the store: `const store = await cookies(); store.set('theme', 'dark', { httpOnly: true, sameSite: 'lax', path: '/' })`. There is also `.get()` and `.delete()`. As of Next.js 15 `cookies()` is async, so the `await` is required. Writing is only allowed where a response is still being composed — Server Actions and Route Handlers. A Server Component render is a read-only context: its output may already be streaming to the browser, and headers cannot be set once the response body has begun, so a `.set()` call there throws. That is also why cookie-based state changes — theme, locale, dismissing a banner — are naturally shaped as Server Actions rather than as something a page does while rendering.

code

typescript · 14 lines
typescript
'use server'
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'

export async function setTheme(formData: FormData) {
  const store = await cookies()
  store.set('theme', String(formData.get('theme')), {
    httpOnly: true,
    sameSite: 'lax',
    path: '/',
  })

  revalidatePath('/', 'layout')
}

go deeper

for a junior

Be able to write the four lines: import cookies from next/headers, await it, call set with name, value and options. Say that reads are fine in a render but writes need a Server Action or Route Handler.

for a middle

Explain the reason behind the rule — cookies are response headers and a render may already be streaming — and name the contexts where writes are legal, including Route Handlers and middleware.

for a senior

Treat the cookie write as a mutation: identify which cached views depended on the old value, invalidate them deliberately, and defend your httpOnly, secure and sameSite choices for the cookie's purpose.

for a principal

Decide what belongs in a cookie at all versus server-side session state, how preference cookies interact with cacheability of routes across the app, and what that costs you in prerendered output.

## The API `cookies()` lives in `next/headers`. As of Next.js 15 it is asynchronous, so you await it to get the cookie store: ```ts 'use server' import { cookies } from 'next/headers' export async function setTheme(theme: string) { const store = await cookies() store.set('theme', theme, { httpOnly: true, sameSite: 'lax', path: '/' }) } ``` The store exposes `get(name)`, `getAll()`, `has(name)`, `set(...)` and `delete(name)`. `set` accepts either `(name, value, options)` or a single object, and the options are the usual cookie attributes: `httpOnly`, `secure`, `sameSite`, `path`, `maxAge`, `expires`, `domain`. ## Why the context matters A cookie is set with a response header. Headers must be written before the response body starts. That single constraint explains the whole rule: - **Server Action** — runs in response to a client-initiated POST, before that response is composed. Writes are allowed. - **Route Handler** — you are authoring the response. Writes are allowed. - **Middleware** — runs before the response and can set cookies on the response it returns. - **Server Component render** — may already be streaming HTML to the browser. There is no longer a header section to modify, so a write throws rather than silently doing nothing. So in a Server Component you can *read* cookies to decide what to render, and that read is a dynamic input: consulting the request's cookies means the render cannot be produced without a request. ## The shape this pushes your UI into Because writes need an action, cookie-backed preferences end up as forms or action calls: ```tsx // app/settings/page.tsx import { setTheme } from './actions' export default function Page() { return ( <form action={async (formData: FormData) => { 'use server' await setTheme(String(formData.get('theme'))) }}> <button name="theme" value="dark">Dark</button> </form> ) } ``` That is the intended shape: a mutation, invoked deliberately, with a response to attach the header to. ## Cookie writes are a mutation too The part candidates miss: setting a cookie usually changes what pages should render. If a layout reads the `theme` cookie, or a locale cookie selects which content a page fetches, then the cookie write has invalidated views the same way a database write would. The action often needs to follow the write with a `revalidatePath` call from `next/cache` so cached renders that depended on the old cookie value are not served back. ```ts 'use server' import { cookies } from 'next/headers' import { revalidatePath } from 'next/cache' export async function setLocale(locale: string) { const store = await cookies() store.set('locale', locale, { path: '/' }) revalidatePath('/', 'layout') } ``` Routes that read cookies during render are dynamic and are not statically cached in the first place — but any tagged or path-cached data they render alongside can still be stale, which is why the invalidation call is not automatic. ## Security defaults worth saying out loud A session or auth cookie should be `httpOnly` so client JavaScript cannot read it, `secure` in production so it is not sent over plain HTTP, and given an explicit `sameSite` — `lax` is the usual default for a cookie that must survive top-level navigations. A preference cookie the client also needs to read (a theme applied before hydration, say) is a legitimate exception to `httpOnly`, and you should be able to say why you made that exception. ## Deleting `store.delete('theme')` expires the cookie. Deleting is subject to the same context rule: it emits a `Set-Cookie` header, so it only works where a write is legal. ## What an interviewer is listening for The correct API with the `await`, the read-versus-write asymmetry between render and action, the header-timing reason behind it, and ideally the observation that a cookie write is a mutation whose effect on cached views you may still have to declare.

  • Can a Server Component read cookies, and what does doing so cost?
    Yes — awaiting cookies() and calling get() is fine in a render. The cost is that the render now depends on the incoming request, so that route cannot be produced ahead of time and is rendered per request instead of served from a prebuilt output.
  • After the action sets the cookie, does the currently displayed page re-render with the new value?
    Next re-renders the current route after the action returns and sends a fresh RSC payload, so a component reading the cookie during that render sees the new value. Other routes still sitting in the client Router Cache do not, which is why a revalidatePath call is often paired with the write.

saying these in an interview costs you the question

  • Calls cookies() without awaiting it on Next.js 15+
  • Tries to set a cookie inside a Server Component render
  • Thinks document.cookie in a client component is equivalent for auth
  • Omits httpOnly and secure on a session cookie
  • Assumes a cookie write needs no cache invalidation anywhere

context