.postmortem/rsc-header-detection.md
You cannot detect an RSC request by reading the RSC header in Next.js.
The browser sends RSC: 1 on a soft navigation, but Next.js classifies
rsc, next-router-state-tree, next-router-prefetch, etc. as internal
Flight headers and strips them from every user-accessible surface before
user code runs. Both a Server Component's headers() and a proxy's
request.headers see null. Any RSC detection built on reading these
headers is dead on arrival, and contributors keep reintroducing it.
This logic has cycled through four PRs, each trading one real problem for another:
RSC: 1).cookies().set() then delete() to test writability.cookies().set() unconditionally invalidates the router cache,
causing infinite refresh loops (#8464) and a leaked probe cookie
(#8828). It assumed RSC: 1 is present on client-side flight
requests. On Next.js 16 it is not.x-better-auth-is-rsc header. The proxy never sees it either.FLIGHT_HEADERS is deleted in two independent places, both before user
code runs (pinned to Next.js v16.3.0-canary.36, SHA 58e8c0b):
NextRequestHint:
server/web/adapter.tsheaders() path, in getHeaders():
server/async-storage/request-store.tsNext.js does this on purpose, so an RSC request is never handled differently from its HTML counterpart. The behavior is documented under RSC requests and rewrites.
// WRONG - always null in RSC, with or without a proxy
const isRSC = (await headers()).get("RSC") === "1"
// ALSO WRONG - the proxy strips it too, nothing to forward
requestHeaders.set("x-better-auth-is-rsc", request.headers.get("RSC"))
cookies().set()
invalidates the router cache on every call. Unacceptable side effect.The regression tests in next-js.test.ts mock next/headers:
headers: vi.fn(async () => new Headers({ RSC: "1" }))
new Headers({ RSC: "1" }) is an input the real Next.js runtime never
produces, because it strips the header first. A mock returns whatever
the test feeds it, so the suite only proves the code agrees with the
mock, never that the mock matches the runtime. It is the inherent limit
of mocking a boundary: you stub the boundary's output instead of exercising
the rule that produces it.
Run a real Next.js app, not a unit test. A Server Component that dumps
(await headers()).get("RSC") on a soft navigation prints null, even
though DevTools shows RSC: 1 on the ?_rsc=... request. A proxy.ts
logging request.headers.get("RSC") also prints null.
RSC
from headers() or request.headers always returns null.headers() to return a value the runtime never emits,
so a green suite only proved the code agreed with the mock. Mocking a
boundary stubs its output, it does not exercise the rule that strips
the header. Behavior that depends on that rule belongs in a real-app
or e2e check.RSC header.RSC / next-router-* from headers() or a
proxy. Link the two Next.js source lines above.