❯ cat /blog/navigation-that-secretly-reloaded.md
· 7 min read · forensics · next.js · vercel · rsc
The navigation that secretly reloaded: a Vercel RSC forensic
Every page worked, every link navigated, and every single client-side navigation was quietly failing with a 404. A forensic on the gap between 'it works' and 'it does what you built' — and on bugs that only exist in production's body, never on your machine.
symptom
This site had just shipped view transitions: navigate between pages and titles morph, content crossfades, no flash. It looked great locally. It looked great in production too — until the network tab told a different story:
GET /about?_rsc=6uX5vha9H4xuNaaV 404 GET /work?_rsc=6uX5vha9H4xuNaaV 404 GET /blog/forensic-method?_rsc=... 404 GET /?_rsc=6uX5vha9H4xuNaaV 404
Those ?_rsc=requests are React Server Component payloads: what the Next.js router fetches to navigate client-side instead of reloading the document. All of them were 404ing. And yet the site navigated fine. That contradiction is the actual symptom: when a failing request doesn't produce a failing experience, something is silently absorbing the failure.
The absorber is the router's fallback: when an RSC fetch fails, Next.js gives up on soft navigation and performs a full document load. Every click on the site was a disguised page reload. The pages rendered, the links worked — and the view transitions I had just shipped never ran for a single visitor, because they only exist on soft navigations.
evidence
One detail before the experiments: this site serves English unprefixed and Spanish under /es, implemented with a rewrite that maps /work to the prerendered /en/work. Keep that in mind. Now, isolate the variables:
curl /about -> 200 document, fine curl -H "RSC: 1" /about?_rsc=test -> 404 payload, dead curl -H "RSC: 1" /es/about?_rsc=test -> 200 payload, fine (!) curl -H "RSC: 1" /?_rsc=test -> 200 payload, fine (!)
The pattern is the confession: Spanish routes (real paths, no rewrite involved) serve their payloads. Unprefixed English routes (which only exist via the rewrite) do not. The bug lives in the interaction between RSC requests and the locale rewrite. One more experiment to find the mechanism:
curl /work.rsc -> 404 curl /es/work.rsc -> 200 curl /index.rsc -> 200
There it is. On Vercel, prerendered RSC payloads are materialized as real files with a .rsc suffix, and navigation requests are resolved against those paths. My rewrite had a guard excluding any path containing a dot — the standard trick to keep static files like cv.pdf out of the locale mapping. /work.rsc contains a dot. The rewrite politely stepped aside, and the platform answered 404.
why local never saw it
The site had been smoke-tested locally against a production build: every route, both languages, transitions visibly working. None of it caught this, because the .rscsuffix does not exist locally. A local Next.js server negotiates RSC responses by request header on the same URL; the suffixed-file form is an artifact of Vercel's build output. The bug was born in deployment packaging — a layer below anything npm run start can reproduce.
Local parity is a spectrum, not a boolean. Your dev server emulates the framework; it does not emulate what your hosting platform compiles the framework into.
convicting without a staging environment
The fix was one dedicated rewrite: map unprefixed .rsc requests to their /en/equivalents, excluding real Spanish payloads and internals. But proving it before shipping had a complication: this project's preview deployments sit behind Vercel's authentication, so there was no public staging URL to curl.
The workaround is to interrogate the artifact instead of the environment. Next compiles every rewrite into a regex inside .next/routes-manifest.json — the exact regex the edge will execute. Load it in Node, run the ten paths that matter through it:
/work.rsc -> MATCH dest=/en/work.rsc /blog/forensic-method.rsc -> MATCH dest=/en/blog/... /es.rsc -> no match (real file wins) /es/work.rsc -> no match (real file wins) /api/cv.rsc -> no match /cv.pdf -> no match
Same inputs, same regex engine, same outcome the platform will produce. Shipped it; production confirmed: seven previously-404 payload endpoints at 200, nine regression routes untouched, and the morphing titles finally performing for an audience.
epilogue: the ghost came back twice
Hours after the fix, the 404s reappeared — only on /, only when browsing in English. Two findings explained the first return. Vercel's CDN excludes the _rsccache-buster from its cache key and varies on RSC headers, so poisoned pre-fix entries kept being served for header combinations my probes hadn't touched. And the root is special: Vercel normalizes its payload path back to / before user rewrites run, so the suffix rule never saw it, and RSC requests for the homepage received polite, well-formed, completely wrong HTML. A second rule routed the root by the RSC request header itself, straight to the flight file.
Then it came back again — in the browser. Every curl I could compose said 200; the console kept saying 404. The instrument was the problem: Next.js 16 also prefetches individual segments, tagged with a Next-Router-Segment-Prefetch header that only a real browser sends, and Vercel resolves those as /index.segments/* files for the root — a third place where the root is special. The capture that cracked it was a headless Chromium scripted to log the full request headers of anything that failed; one run surfaced what twenty curls could not. A third rule mapped the segment files, and the console finally went quiet.
lessons
- curl is not a browser.Frameworks speak to themselves with headers your hand-built requests don't know exist. When curl says 200 and the console says 404, stop refining the curl — script a real browser and read the headers of the failing request.
- "It works" is not a health check. Graceful degradation is a gift to users and a trap for operators: the better your fallbacks, the quieter your failures. The only witness to this bug was the network tab.
- Smoke tests that assert status codes on documents miss entire request classes. If your framework navigates via payload fetches, test the payload fetch:
curl -H "RSC: 1" /route?_rsc=xis now part of this site's post-deploy checklist. - When staging is unavailable, the build artifact is admissible evidence. The compiled routes manifest is what production executes — simulating it is verification, not guesswork.
- CDNs preserve your mistakes: the 404s came back with an
Ageof thirteen hours. Caches don't forgive; they archive. A redeploy is also a purge.
This is the third investigation on this site that started with someone saying "everything looks fine" — after the capital letter that killed a build and the method that catches these things. The pattern holds: the dangerous bugs aren't the ones that crash. They're the ones that work.