Skip to content

Headers and cookies

Setting response headers and cookies during a render.

Updated View as Markdown

Reading

Anywhere in a server component or action:

import { headers, cookies, searchParams } from '@rsc-kit/core/request'

export default async function Page() {
  const h = await headers()
  const jar = await cookies()

  return <p>Hello {jar.get('name')?.value ?? 'stranger'}</p>
}

Writing

import { responseHeaders, cookies } from '@rsc-kit/core/request'

export async function middleware() {
  responseHeaders().set('X-Frame-Options', 'DENY')

  const jar = await cookies()
  jar.set('last-seen', new Date().toISOString(), { httpOnly: true, path: '/' })
}

Writing only works in middleware. Middleware runs before the render, while the response line has not been sent yet. A component runs during streaming, when the headers are already on the wire — writing from one throws rather than being silently dropped, so you find out immediately.

A redirect carries them too, which is what lets middleware remember where someone was going before sending them to log in:

export async function middleware() {
  const jar = await cookies()

  if (!jar.get('session')) {
    jar.set('intended', '/dashboard', { path: '/' })
    redirect('/login')
  }
}

Security headers

Nothing sets them for you, because which ones an app wants is the app’s decision. The ones that are the same on every page go in the config:

vite.config.tsts
rscKit({
  headers: {
    'X-Content-Type-Options': 'nosniff',
    'Referrer-Policy': 'strict-origin-when-cross-origin',
    'X-Frame-Options': 'DENY',
    'Strict-Transport-Security': 'max-age=63072000; includeSubDomains',
  },
})

Every response gets them, and a page stays one an edge may serve from its cache: they are fixed for the build, so the edge sends them with its copy. A middleware’s own value for the same header wins.

Only the headers an edge can send with a cached page are accepted: Content-Language, X-Robots-Tag, Content-Security-Policy, Permissions-Policy, Referrer-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, Cross-Origin-Resource-Policy and X-DNS-Prefetch-Control. Anything else is a build error that says so. Cache-Control and Link are left out because the renderer sets them per response.

A header that depends on the request goes in a middleware instead. A middleware runs per request, so a page under one is never served from an edge’s cache: a root middleware.ts keeps every page out.

A Content-Security-Policy is the one that needs more than a header: a page stored at build time cannot carry a per-request nonce. That is on the list, and until then a policy that allows your own origin’s scripts is the honest one.

jar.set('name', 'value', {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',      // 'strict' | 'lax' | 'none'
  path: '/',
  maxAge: 60 * 60 * 24,
  expires: new Date('2027-01-01'),
})

Names are validated as cookie tokens and sameSite / expires are checked, so a typo is an error rather than a header the browser quietly ignores.

A cookie set is the cookie read, for the rest of the request. After jar.set('agent', id), every cookies().get('agent') in the same request — in the action, and in the sections it revalidate()s, which render in the same request and travel back with the answer — sees id, not what the browser sent; a deleted one is gone from get, has and getAll. This is Next’s rule too, and it is what makes set then revalidate('all') the whole of a switcher: the sidebar re-renders with the choice the browser is about to hold. The next request starts from what it arrives with.

What a response says about itself

Two things, and they are not the same kind:

How it was served, always: X-RSC-Kit: stored, rendered or shell on every response — a page from a file the build wrote, a page rendered for this visitor, or a stored shell with its holes rendered now. It is the header a developer reads in the Network tab when a page is slower than expected, the way X-Nextjs-Cache is, and a CDN rule or a health check can key on it. It names no product, so there is nothing to strip.

What built it, by default: X-Powered-By: rsc-kit on every response and <meta name="generator" content="rsc-kit"> in every document — what BuiltWith and Wappalyzer read. The name only, never the version: a version in every response is what a vulnerability scanner filters on. A policy that strips every framework identifier turns both off, which matters for the tag, since a proxy can strip a header but not a line inside the HTML:

vite.config.tsts
rscKit({ identify: false })
Navigation

Type to search…

↑↓ navigate↵ selectEsc close