Skip to content

Next.js 16.3 desde la silla del CTO: cinco apps, un mes, tres bugs

· 9 min de lectura

tags: nextjs, leadership

Next.js 16.3 salió a principios de agosto con el titular "la actualización más grande desde 16.0". La adoptamos esa misma semana en cinco apps en producción, después convivimos un mes con ella, y esta semana pasamos todo al último patch y revisamos de nuevo cada afirmación del release post. Esto es lo que se sostuvo, lo que no, y lo que le diría a otro CTO antes de que active los flags.

Contexto, para que los números signifiquen algo: corremos cinco apps de Next.js. Tres viven en un monorepo de pnpm: un producto de administración de 56 rutas detrás de auth, un sitio público para candidatos con formularios y entrevistas con IA, y un portal chico para empleados. Las otras dos están en repos separados: un dashboard interno de analytics y un sitio de marketing en tres idiomas con un blog en MDX. Las cinco hacen deploy en Vercel. Las cinco corren hoy con React Compiler y Cache Components.

Lo que rindió desde el primer día

El cache de build es real. El cache en filesystem de Turbopack para next build viene activado por defecto en 16.3. Medido esta semana con el último patch, en una laptop: la app de 56 rutas compila en 30 segundos en frío y en 0,8 segundos cuando no cambió nada. En 16.3.0 el mismo rebuild con cache tardaba 6,6 segundos, así que la línea de patches lo mejoró ocho veces sin que tocáramos nada. Las apps más chicas andan entre 4 y 7 segundos en frío y medio segundo con cache. El tiempo total es mayor (la recolección de page data y la generación estática no se cachean), pero el paso de compilación era la parte que nos tenía esperando.

La evicción de memoria cambió una decisión, no un número. Nunca medimos la memoria en dev, pero es la razón por la que nuestro sitio de marketing finalmente dejó next dev --webpack como default. Las sesiones largas de Turbopack crecían hasta que la laptop se quejaba; con la evicción activada por defecto, eso dejó de ser un motivo para mantener webpack. Webpack queda como script de fallback, y eso trae su propia lección más abajo.

catchError es el error boundary que realmente queríamos. Un error.tsx clásico se traga notFound() y redirect(), y su reset solo limpia el estado del cliente. El nuevo boundary de next/error no hace ninguna de las dos cosas: deja pasar esas dos, y su retry() vuelve a hacer fetch de los Server Components que tiene abajo. Lo usamos alrededor de páginas que hacen fetch en el server (con el fallback reportando a Sentry) y, en el blog, alrededor del cuerpo MDX, para que un post roto se degrade a un bloque de "volvé a intentar" en vez de una página en blanco.

TypeScript 7 en next build, con una trampa de pnpm. Cuatro de las cinco apps hacen type-check con el tsc nativo. La que no es el sitio de marketing, porque lintea con typescript-eslint, cuyo parser se rompía con la API de TypeScript 7 cuando lo probamos. Las apps que lintean con Biome nunca tuvieron ese problema. Y con el linker aislado de pnpm, el compilador nativo no incluye automáticamente node_modules/@types, así que cada app necesitó un array "types" explícito en su tsconfig. Diez minutos, pero diez minutos confusos.

Cache Components cambia la semántica HTTP, no solo la velocidad

Esta es la parte que pondría en negrita en cada guía de migración. Con Cache Components una página hace streaming sobre un shell prerenderizado, y el shell sale con un 200. Si después tu página llama a notFound() mientras renderiza la parte dinámica, el status ya está en el cable. El usuario ve tu UI de not-found. Google ve un 200.

Lo medimos en el sitio de marketing antes de arreglarlo: un slug de blog inexistente, un slug de landing de anuncios inexistente y un slug de página de comparación inexistente respondían todos 200. Tres familias de rutas enteras devolviendo "encontrado" para cualquier basura, justo en la propiedad donde los crawlers son el cliente. El arreglo sigue la guía del propio framework: establecer que la página existe antes del primer byte. Nuestro proxy ahora valida los slugs del blog contra un manifest generado en build (así no puede quedar desactualizado en producción, y lleva las fechas de publicación para que los posts programados sigan funcionando), valida los otros dos contra sus tablas estáticas, y reescribe lo que no matchea a un path que ninguna ruta atiende. El router entonces sirve la página de not-found con nuestra marca y un 404 de verdad.

El segundo cambio semántico es que los segment configs desaparecieron. export const revalidate = 3600, dynamic = "force-static", dynamic = "force-dynamic": ninguno está permitido una vez que activás el flag. No es un renombre. revalidate pasa a ser un scope 'use cache' con cacheLife('hours'), y todo lo que lea el reloj se tiene que mover adentro de ese scope. Nuestro blog filtra posts por fecha de publicación con new Date(); fuera de un scope de cache eso es data de request y un error de prerender directo, adentro el reloj se lee una vez por entrada de cache y los posts programados igual aparecen dentro de la hora. Una vez hecho eso, se prerenderizaron 204 páginas en build y un segundo hit a un post pasó de 129 ms a 29 ms.

Root params es lo que destraba i18n, y todavía no lo encaramos

El ejemplo de import.meta.glob del release post es un blog que lee archivos MDX con gray-matter. Es literalmente el nuestro, así que lo probamos en agosto. Compiló, y pasó las rutas del blog de dinámicas a completamente estáticas, lo que rompió el locale en tiempo de request de next-intl con 500s en runtime. Las lecturas sincrónicas de fs son estructurales hasta que el layout deje de derivar el locale de params.

Ese mismo layout es la razón por la que el sitio de marketing lleva export const instant = false en todo su árbol de locale: un layout que elige <html lang> y sus mensajes a partir de params no puede producir un shell estático instantáneo. Root params (lang() de next/root-params) más setRequestLocale es la solución, y es el próximo paso para esa app. Si tu app está internacionalizada con un segmento [locale], esa migración es el precio de Instant Navigations, y conviene presupuestarla de entrada en vez de descubrirla a través de instant = false.

Experimental quiere decir experimental

El React Compiler basado en Rust es la feature que más me entusiasmaba y la que más nos costó. Pasaron dos cosas.

Primero, en nuestra app más grande mata el build por OOM en el contenedor estándar de Vercel, unos 25 segundos después de arrancar una compilación en frío. La misma app buildea bien por el camino de Babel (el doble de lento, pero entra), y las apps más chicas buildean bien con el port de Rust. Lo reportamos, un maintainer se involucró ese mismo día, y para fines de agosto habían reducido a la mitad el pico de memoria del compilador, con un camino de crecimiento cuadrático como el probable disparador en nuestro código. Ese trabajo está en canary. Esta semana volvimos a correr el experimento con el último patch de 16.3, en un preview deployment, y nos dio el mismo SIGKILL. Así que nuestra config mantiene un guard por target: port de Rust en todos lados excepto en los builds en Vercel de esa app, donde el compilador corre por Babel.

Segundo, el port de Rust pierde la clase de scoping jsx-<hash> en elementos renderizados condicionalmente dentro de componentes con estado, así que las reglas scoped de <style jsx> dejan de matchear sin avisar. Nos dejó los labels del sidebar invisibles en producción. Mantuvimos un repositorio con un repro mínimo (vercel/next.js#96694) y esta semana lo volvimos a correr contra tres versiones: sigue roto en el último patch de 16.3, arreglado en el canary de 16.4. Eso convirtió un "capaz ya está arreglado" en un número de versión, y significa que nuestra regla de "nada de styled-jsx scoped" se queda exactamente hasta 16.4.

Dos cosas más chicas del mismo rubro. output: 'standalone' combinado con un adapter rompía los deploys en Vercel en 16.3.0 (faltaba un archivo de trace); el fix se mergeó a canary dos días después y, a esta semana, todavía no salió en ningún patch estable de 16.3. Fijate el tag, no el PR. Y el guard de config para nuestro fallback a webpack originalmente miraba process.argv buscando --webpack, lo que nunca matchea porque next.config se evalúa en el proceso interno del server de Next. Un reviewer lo detectó corriendo de verdad el fallback. Un guard que nunca ejercitás es un guard que no tenés.

El bug que no tenía nada que ver con Next.js

Toda la adopción de 16.3 del sitio de marketing se revisó, se aprobó y se mergeó el 4 de agosto. Se mergeó a la feature branch sobre la que estaba apilada, y esa branch nunca se volvió a mergear. Durante un mes el sitio corrió un binario de 16.3 con webpack en dev, sin compilador y con los segment configs viejos. No se rompió nada, que es exactamente por qué nadie se dio cuenta, y lo encontré recién esta semana haciendo grep en main buscando un flag que tendría que haber estado ahí. Volvimos a aplicar la intención a mano; un cherry-pick tenía diez archivos en conflicto contra un mes de trabajo de diseño. Los PRs apilados están bien. Los PRs apilados cuya base no es main necesitan un recordatorio en la base.

Lo que le diría a otro CTO

  • Tomá los patches. 16.3.3 arregló dos advisories críticos de ejecución remota de código, uno de ellos en la optimización de imágenes con AVIF, que es el primer formato que sirve nuestro sitio de marketing. Nada en la línea 16.3.x nos exigió cambios de código. No hay motivo para quedarse en 16.3.0.
  • Medí tus 404 después de activar Cache Components. Un curl por familia de rutas. Si ves 200 donde esperás 404, mové los chequeos de existencia al proxy.
  • Tratá revalidate como una migración. Encontrá cada segment config, encontrá lo que lee el reloj, movelo adentro de un scope de cache.
  • Dale a cada flag experimental una salida de emergencia por target y una forma de volver a testearlo. La nuestra es un commit en el preview de un pull request y un revert. Seis minutos, y ahora sabemos la respuesta para este patch.
  • Guardá un repo de repro por cada bug que reportes. Volver a correrlo contra una versión nueva cuesta dos minutos y responde la pregunta que los comentarios upstream no pueden responder.
  • Un fix en canary no es un fix en tu build. Fijate el release tag.
  • Si estás internacionalizado, planificá la migración a root params antes de planificar Instant Navigations. Es el mismo proyecto.

16.3 es un buen release. Solo el cache de build y el error boundary ya justificaban el upgrade, y Cache Components es el modelo correcto para las apps que construimos. Pero el release post describe un destino, y el camino hasta ahí pasa por tu proxy, tu layout de i18n y el límite de memoria de tu contenedor de build. Saber eso antes de arrancar es la mayor parte del trabajo.