Skip to content

feat(rsc): add unstable_headers for setting response headers - #15729

Open
brookslybrand wants to merge 3 commits into
mainfrom
brooks/rsc-unstable-headers
Open

brookslybrand wants to merge 3 commits into
mainfrom
brooks/rsc-unstable-headers

Conversation

@brookslybrand

@brookslybrand brookslybrand commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What problem is this PR solving?

  • RSC server functions can't set response headers. Today the only way is redirect()/replace(), which navigates (and resets scroll), so setting a cookie from a server function needs a redirect or app-level plumbing.
  • Adds unstable_headers(), which returns the outgoing response Headers for the current request. It works in server functions, loaders, actions, resource routes, and middleware (before next()), in RSC Data and RSC Framework Mode.
  • The headers are added to every response React Router builds: render, action, side-effect and thrown redirects, CSRF errors, resource routes, and responses middleware returns without calling next() (Flight or not).
  • set, append, and delete calls are replayed in order on the final response, on top of its other headers (route headers exports, returned/thrown responses, and anything middleware set after next()). Set-Cookie is always appended.
  • Headers React Router manages (Content-Type, Content-Length, Location, React-Router-Resource, X-Remix-*) can't be set, since overriding them breaks the protocol (e.g. a stale Content-Length truncates the HTML document).
  • Writes throw once the response has been created, including through a saved reference and while server components render (Flight starts rendering in a microtask after new Response()).
"use server";
import { unstable_headers } from "react-router";

export async function setTheme(formData: FormData) {
  unstable_headers().append(
    "Set-Cookie",
    `theme=${formData.get("theme")}; Path=/`,
  );
}

Context: reactrouter.com's RSC migration (remix-run/react-router-website#258) sets its color scheme cookie with an AsyncLocalStorage slot in root middleware to avoid a redirect. This replaces that with one call. Next.js covers the same need with cookies().set() in server functions.

Implementation

  • ServerContext.headers is a Headers subclass that guards writes (reserved names, after the response exists) and records non-cookie operations so they can be replayed with their real set/append/delete meaning.
  • matchRSCServerRequest creates the context and applies the headers once to the final response, for both render and resource routes. Intermediate responses (e.g. from next()) never get them, so nothing is applied twice.
  • generateRenderResponse locks the headers when it calls generateResponse, before Flight renders any server component.
  • Resource routes now run inside ServerStorage too, so they (and middleware above them) can use unstable_headers(), and unstable_getRequest() works there as well.

Open questions

@jacob-ebey, thanks for the implementation guide. A few calls I'd like your take on:

  1. Name. I went with unstable_headers() per your suggestion. In Next.js, headers() is the read-only request headers (cookies() is the one that writes), so unstable_headers().get("Cookie") silently returning null could trip people up. It also collides with the route module headers export if someone imports it as headers, so the docs use unstable_headers directly. Alternatives: unstable_responseHeaders(), or a cookies()-style API. Thoughts?
  2. Precedence. Operations are replayed on the final response, on top of route headers exports, redirect responses, and headers middleware set after next(), so an explicit call is never silently dropped and append("Vary", …) really appends. The other option is to seed them as defaults (like side-effect redirect headers today), which route headers functions can override. Which do you prefer?
  3. Render pass. Your guide mentioned the sync render pass up to the first await. Since generateResponse constructs the Response before Flight runs any component, render-time calls always throw here, with an error pointing to loaders, actions, server functions, and middleware. Supporting render would mean deferring response construction. Is throwing OK for now?
  4. Resource routes. I originally left these out, but then unstable_headers() in root middleware made every resource route 500, so they're now supported via their own ServerStorage.run. That also makes unstable_getRequest() work in resource routes. OK with that?
  5. status(). Left out to keep this focused. Mostly useful for no-JS form posts. Happy to do it as a follow-up.

🎩 Tophatting

Unit tests in packages/react-router/__tests__/rsc/headers-test.ts and an integration test in integration/rsc/rsc-test.ts ("Supports unstable_headers in loaders and server functions") cover:

  1. Server function (JS and no-JS) appends Set-Cookie without redirecting → cookie on the response; with JS, document.cookie updates
  2. Server function sets headers and throws a redirect (JS and no-JS) → headers merged, headers() value wins over the redirect's
  3. Loader, action, middleware, and resource route set headers → present on the response, including thrown resource responses
  4. Middleware sets a cookie, then another middleware returns its own Markdown or Flight response → cookie still added
  5. Route headers sets Vary/Cache-Control/X-Remove, loader appends/sets/deletes → Accept-Encoding, Cookie, no-store, removed
  6. Setting Content-Length, Content-Type, Location, React-Router-Resource, or X-Remix-* → throws
  7. Server component calls unstable_headers(), or a deferred promise writes to a saved reference → throws "headers can't be used after the response has been created"

Generated by an AI agent on behalf of Brooks Lybrand. Model: Claude Opus 5.5; reasoning: unknown.

Server functions, loaders, actions, and middleware (before next()) can
set response headers via unstable_headers(), so server functions can
set cookies without redirecting. Set-Cookie headers are appended; other
headers replace values from route headers exports or redirect responses.
It throws once the response has been created, including while server
components render.
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Preview Build Available

Preview builds have been created for this PR. You can install react-router using:

pnpm install "remix-run/react-router#preview/pr-15729&path:packages/react-router"

And/or install other packages via:

pnpm install "remix-run/react-router#preview/pr-15729&path:packages/react-router-dev"
pnpm install "remix-run/react-router#preview/pr-15729&path:packages/react-router-express"
pnpm install "remix-run/react-router#preview/pr-15729&path:packages/react-router-node"
pnpm install "remix-run/react-router#preview/pr-15729&path:packages/react-router-serve"

These preview builds will be updated automatically as you push new commits.

- Support resource routes (and middleware above them) by running them in
  ServerStorage, which also makes unstable_getRequest work there
- Add headers to Flight responses middleware returns without next()
- Reject React Router managed headers (Content-Length, Content-Type,
  Location, React-Router-Resource, X-Remix-*)
- Throw on writes through a saved reference after the response exists
- Replay set/append/delete in order instead of always replacing
- Skip exact duplicate Set-Cookie headers
- Explain when another react-router copy handles the request
- Stop aliasing unstable_headers as headers in docs and tests
Apply the headers once to the final response in matchRSCServerRequest
instead of at every response construction point. This removes the
response tracking, per-path helpers, and cookie de-duplication, since
intermediate responses never get the headers. Consolidate tests.
@jacob-ebey

Copy link
Copy Markdown
Member
  1. Can we just go with responseHeaders() and drop the unstable prefix since it's a new API in an inherently unstable codepath? I don't know if a cookies() API fits with our existing cookie / session utils as they operate on set header APIs, not "cookies". We already have a getRequest() API that exposes the request, and therefore headers. Instead of a headers() or cookies() API, I feel decent with the name responseHeaders() returning a headers object with setting / override rules (we should already have an implementation of this I believe)
  2. I'd have to look closer here. I'd prefer whatever merge order applies between our existing route action / loader coordination. Server action headers should, at least at first thought, should always apply and never be overridden, so a replay on top of existing logic might be the way to go?
  3. We can skip render time redirects for now. I forget if we run loader logic outside or inside of the react tree. the RSC renderToReadableStream call should (if we run loader logic before) allow us to call the responseHeaders() in route components between renderToReadableStream() and the new Response() construction, but if this is giving you problems we can ignore it for now and I can re-visit this at a later time.
  4. That should be fine if we are exposing the responseHeaders() API to existing route loader and actions. If we do not want side-effectful functions in existing loader and actions, we can drop it from middleware as well. This is a decision I leave to the community / steering committee.

@jacob-ebey jacob-ebey left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants