Skip to content
a@o:~$

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:

devtools · network
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:

reproduction
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:

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:

node · routes-manifest simulation
/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.