Saltar al contenido
a@o:~$

cat /blog/navigation-that-secretly-reloaded.md

· 7 min de lectura · forense · next.js · vercel · rsc

La navegación que recargaba en secreto: forense de RSC en Vercel

Todas las páginas funcionaban, todos los links navegaban, y cada navegación client-side estaba fallando en silencio con un 404. Un forense sobre la brecha entre 'funciona' y 'hace lo que construiste' — y sobre los bugs que solo existen en el cuerpo de producción, nunca en tu máquina.

síntoma

Este sitio acababa de estrenar view transitions: navegas entre páginas y los títulos hacen morph, el contenido se desvanece suave, cero flash. Se veía increíble en local. Se veía increíble en producción también — hasta que la pestaña de Network contó otra historia:

devtools · network
GET /about?_rsc=6uX5vha9H4xuNaaV     404
GET /work?_rsc=6uX5vha9H4xuNaaV      404
GET /blog/forensic-method?_rsc=...   404
GET /?_rsc=6uX5vha9H4xuNaaV          404

Esas peticiones ?_rsc= son payloads de React Server Components: lo que el router de Next.js descarga para navegar client-side en lugar de recargar el documento. Todas daban 404. Y sin embargo el sitio navegaba bien. Esa contradicción es el verdadero síntoma: cuando una petición que falla no produce una experiencia que falla, algo está absorbiendo la falla en silencio.

El absorbedor es el fallback del router: cuando el fetch RSC falla, Next.js renuncia a la navegación suave y hace una carga completa del documento. Cada click en el sitio era una recarga disfrazada. Las páginas renderizaban, los links funcionaban — y las view transitions que acababa de publicar jamás corrieron para un solo visitante, porque solo existen en navegaciones suaves.

evidencia

Un detalle antes de los experimentos: este sitio sirve el inglés sin prefijo y el español bajo /es, implementado con un rewrite que mapea /work al /en/work prerenderizado. Tenlo presente. Ahora, a aislar variables:

reproducción
curl /about                          -> 200   documento, bien
curl -H "RSC: 1" /about?_rsc=test    -> 404   payload, muerto
curl -H "RSC: 1" /es/about?_rsc=test -> 200   payload, bien (!)
curl -H "RSC: 1" /?_rsc=test         -> 200   payload, bien (!)

El patrón es la confesión: las rutas en español (paths reales, sin rewrite de por medio) sirven sus payloads. Las rutas en inglés sin prefijo (que solo existen vía el rewrite) no. El bug vive en la interacción entre las peticiones RSC y el rewrite de idioma. Un experimento más para encontrar el mecanismo:

mecanismo
curl /work.rsc     -> 404
curl /es/work.rsc  -> 200
curl /index.rsc    -> 200

Ahí está. En Vercel, los payloads RSC prerenderizados se materializan como archivos reales con sufijo .rsc, y las navegaciones se resuelven contra esos paths. Mi rewrite tenía una guarda que excluía cualquier path con punto — el truco estándar para que archivos estáticos como cv.pdf no entren al mapeo de idioma. /work.rsc tiene un punto. El rewrite se hizo a un lado educadamente, y la plataforma respondió 404.

por qué local nunca lo vio

El sitio se había probado en local contra un build de producción: todas las rutas, ambos idiomas, transiciones funcionando a la vista. Nada de eso lo atrapó, porque el sufijo .rsc no existe en local. Un servidor de Next.js local negocia las respuestas RSC por header sobre la misma URL; la forma de archivo con sufijo es un artefacto del build output de Vercel. El bug nació en el empaquetado del deployment — una capa por debajo de cualquier cosa que npm run start pueda reproducir.

La paridad con local es un espectro, no un booleano. Tu dev server emula al framework; no emula aquello en lo que tu plataforma de hosting compila al framework.

condenar sin ambiente de staging

El fix fue un rewrite dedicado: mapear las peticiones .rsc sin prefijo a sus equivalentes /en/, excluyendo los payloads reales en español y los internos. Pero probarlo antes de publicar tenía una complicación: los deployments de preview de este proyecto están detrás de la autenticación de Vercel, así que no había URL pública de staging para hacer curl.

La salida es interrogar al artefacto en lugar del ambiente. Next compila cada rewrite a un regex dentro de .next/routes-manifest.json — el regex exacto que el edge va a ejecutar. Lo cargas en Node y pasas por él los diez paths que importan:

node · simulación del routes-manifest
/work.rsc                 -> MATCH  dest=/en/work.rsc
/blog/forensic-method.rsc -> MATCH  dest=/en/blog/...
/es.rsc                   -> no match   (el archivo real gana)
/es/work.rsc              -> no match   (el archivo real gana)
/api/cv.rsc               -> no match
/cv.pdf                   -> no match

Mismos inputs, mismo motor de regex, mismo resultado que producirá la plataforma. Se publicó; producción confirmó: siete endpoints de payload que antes daban 404 ahora en 200, nueve rutas de regresión intactas, y los títulos con morph por fin actuando frente a público.

epílogo: el fantasma volvió dos veces

Horas después del fix, los 404 reaparecieron — solo en /, solo navegando en inglés. Dos hallazgos explicaron el primer regreso. El CDN de Vercel excluye el cache-buster _rsc de su cache key y varía por headers RSC, así que entradas envenenadas pre-fix se seguían sirviendo para combinaciones de headers que mis sondas no habían tocado. Y la raíz es especial: Vercel normaliza su path de payload de vuelta a / antes de que corran los rewrites del usuario, así que la regla del sufijo nunca lo vio, y las peticiones RSC del home recibían un HTML educado, bien formado y completamente equivocado. Una segunda regla ruteó la raíz por el propio header RSC, directo al archivo de flight data.

Y entonces volvió otra vez — en el navegador. Todos los curls que pude componer decían 200; la consola seguía diciendo 404. El problema era el instrumento: Next.js 16 también prefetchea segmentos individuales, marcados con un header Next-Router-Segment-Prefetch que solo un navegador real envía, y Vercel los resuelve como archivos /index.segments/* para la raíz — un tercer lugar donde la raíz es especial. La captura que lo destrabó fue un Chromium headless programado para registrar los request headers completos de todo lo que fallara; una corrida sacó a la luz lo que veinte curls no pudieron. Una tercera regla mapeó los archivos de segmentos, y la consola por fin se quedó en silencio.

lecciones

  • curl no es un navegador. Los frameworks hablan consigo mismos con headers que tus peticiones hechas a mano no saben que existen. Cuando curl dice 200 y la consola dice 404, deja de refinar el curl — programa un navegador real y lee los headers de la petición que falla.
  • «Funciona» no es un health check. La degradación elegante es un regalo para los usuarios y una trampa para los operadores: entre mejores tus fallbacks, más silenciosas tus fallas. El único testigo de este bug fue la pestaña de Network.
  • Los smoke tests que verifican status codes de documentos se pierden clases enteras de peticiones. Si tu framework navega vía fetches de payload, prueba el fetch de payload: curl -H "RSC: 1" /ruta?_rsc=x ya es parte del checklist post-deploy de este sitio.
  • Cuando no hay staging, el artefacto de build es evidencia admisible. El manifest de rutas compilado es lo que producción ejecuta — simularlo es verificación, no adivinanza.
  • Los CDNs preservan tus errores: los 404 regresaban con un Age de trece horas. Los cachés no perdonan; archivan. Un redeploy también es una purga.

Esta es la tercera investigación en este sitio que empezó con alguien diciendo «todo se ve bien» — después de la letra mayúscula que mató un build y el método que atrapa estas cosas. El patrón se sostiene: los bugs peligrosos no son los que truenan. Son los que funcionan.