next/font/google descarga archivos woff2 desde fonts.gstatic.com en tiempo de build. Cuando Google rotó el hash de un archivo, mi deploy empezó a fallar con 404 que no lograba reproducir localmente, porque ese mismo fetch ya había funcionado en mi máquina un segundo antes. Arreglo: meter las fuentes en el repo y usar next/font/local, para que los bytes sean un input tuyo.
El síntoma
Un deploy falló dos veces. Nada había cambiado en la app — mismo commit, mismo lockfile, mismo Node.
Error while requesting resource
Received response with status 404 ...
Module not found: Can't resolve '@vercel/turbopack-next/internal/font/google/font'Esa última línea apareció 21 veces. El 404 venía de fonts.gstatic.com.
Lo que asumí
Asumí que había roto algo en el contenedor de deploy. Resolución de dependencias equivocada, una capa mala de Docker, un proxy comiéndose requests. Porque el mismo comando en mi máquina — next build, Next.js 16.2.10 — compilaba bien. Dos veces. Verde.
Esa es la trampa. “Compila local” se sentía como evidencia de que el contenedor estaba roto. No era evidencia de nada.
Por qué eso estaba mal
next/font/google no es un link a un CDN en runtime. Es un descargador en tiempo de build. La propia documentación de Next, que viene en el paquete en node_modules/next/dist/docs/01-app/03-api-reference/02-components/font.md:13, lo dice sin rodeos:
También puedes usar cómodamente todas las Google Fonts. El CSS y los archivos de fuente se descargan en tiempo de build y se auto-hospedan junto con el resto de tus assets estáticos. El navegador no envía ninguna request a Google.
El navegador no envía requests. Las envía tu build. Cada una de ellas es una oportunidad de que el build falle por razones que no tienen nada que ver con tu código.
Este es el mecanismo real, sacado del loader que viene en node_modules/next/dist/compiled/@next/font/dist/google/:
get-google-fonts-url.jsarma una URLcss2?family=...a partir de tus opciones.fetch-css-from-google-fonts.jshace un GET de ese CSS a través defetch-resource.js, que hardcodea un user agent de Chrome 104 para que Google responda con woff2 en vez de ttf.find-font-files-in-css.jsextrae las entradassrc: url(...)— rutas defonts.gstatic.comversionadas y con hash completo.fetch-font-file.jsdescarga cada una y el build la emite en.next/static/media.
El paso 3 es donde muere la hermeticidad. Esas URLs no son inputs estables que tú controles. Son lo que sea que Google haya puesto en la respuesta CSS en el momento en que corrió tu build.
Y fetch-resource.js tiene exactamente una política para un no-200:
if (res.statusCode !== 200) {
reject(new Error(errorMessage || `Request failed: ${url} (status: ${res.statusCode})`));
return;
}Envuelto en retry(fn, 3), que es async-retry, así que es el intento inicial más tres: cuatro requests contra una URL que va a dar 404 cuatro veces.
La ventana de rotación
La request que fallaba era por .../inter/v20/UcCB3Fwr...woff2. Cuando yo mismo consulté el endpoint css2 para esa misma familia, Google estaba devolviendo .../inter/v20/UcCO3Fwr...woff2.
UcCB vs UcCO. Un carácter. Una rotación de hash en pleno vuelo: el CSS que yo tenía apuntaba a una ruta de archivo que ya no existía.
¿Entonces por qué sí compilaba en mi máquina?
Mi primera teoría fue una caché vieja: mi .next era anterior a la rotación, así que el build estaba reproduciendo una respuesta buena desde disco mientras el contenedor pegaba contra el endpoint en vivo. Obvia, prolija y — en este setup — equivocada.
Las cachés del loader son dos Map en memoria creados al principio de loader.js (cssCache, fontCache). Existen para evitar que los compiladores de cliente y servidor descarguen la misma URL dos veces, y mueren con el proceso. La caché en disco de Turbopack sí sobreviviría al proceso, pero experimental.turbopackFileSystemCacheForBuild es opt-in y viene apagada por defecto en Next 16 (node_modules/next/dist/docs/01-app/03-api-reference/08-turbopack.md:204), y este proyecto no la activa. Lo que .next/cache guarda aquí en realidad: .tsbuildinfo, dos archivos chicos de info y exactamente una entrada de fetch-cache — 191.476 bytes, content-type: application/octet-stream, magic bytes de wasm y un campo url con data:application/octet-stream;base64,…. Una data URI. No una fuente, y ni siquiera una llamada de red. No había ninguna respuesta de Google cacheada en mi disco para reproducir.
Así que ambas máquinas sí le preguntaron a Google. Preguntaron en segundos distintos, hasta cuatro veces cada una, y los intentos del host cayeron por casualidad en una respuesta buena.
Sí renombré .next y volví a compilar — y el host reprodujo la falla, lo que se sintió como prueba. No lo era: la ventana mala seguía abierta, y ese mismo build limpio pasó una hora después. Igual renombra en vez de borrar, no te cuesta nada. Pero una reproducción no te vende un mecanismo; verifica que la caché a la que le echas la culpa exista antes de culparla.
La parte más fea: dos severidades para una misma falla
En el host se imprimían 404 y next build igual salía con 0. Dentro del contenedor, esos mismos 404 se convertían en errores duros de resolución de módulos de Turbopack y el build moría.
El caso de exit 0 es retry.js hablando, no una falla tolerada. Cada reintento loguea el error antes de volver a intentar:
onRetry(e, attempt) {
console.error(e.message + `\n\nRetrying ${attempt}/${retries}...`)
}Lo que significa que un log lleno de 404 de fuentes puede querer decir “se recuperó en el intento 3” o “murió en el intento 4”, y las líneas se ven idénticas en ambos casos. Lee el exit code, no el texto que asusta.
Lo que el loader no va a hacer en un build de producción es degradar en silencio — su catch se ramifica según el entorno, y solo dev recibe una tipografía de fallback:
if (isDev) {
// ...return a fallback @font-face instead of throwing
} else {
throw err
}Esa rama es la verdadera trampa para el trabajo local. En next dev la misma URL muerta te entrega una tipografía del sistema vía local(...) con métricas de override, así que la falla que mata tu deploy aparece en tu máquina como una tipografía apenas distinta.
Next.js 16 hace de Turbopack el bundler por defecto para next build (node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md:116), y el camino de Turbopack convierte el error lanzado en módulos @vercel/turbopack-next/internal/font/google/font imposibles de resolver — 21 de ellos en mi log.
La secuencia de diagnóstico
Róbatela, me sacó las adivinanzas de encima:
- Hazle
curl -Ia la URL woff2 exacta que sale en el error. 404 → el archivo ya no está, no es tu red. - Hazle
curlal endpointcss2?family=...de la misma familia con un user agent de Chrome moderno. 200 con un hash distinto ensrc: url(...)→ rotación confirmada, no una caída. mv .next .next-bady vuelve a compilar en local. Una falla acá es una reproducción en vivo — pero antes de darle el crédito a la caché, verifica que hubiera una: la caché de build de Turbopack es opt-in, así que un.nextviejo suele ser inocente.- Corre ese build limpio también en el host, dos veces, con un rato de diferencia. Este es el que la gente se salta, y es el que separa “el archivo ya no está” de “el archivo no estuvo por diez minutos”.
Una hora después, un build limpio pasó con cero 404. Esa es la forma de este bug: una ventana, no un estado — que es exactamente por qué un build verde, en cualquier máquina, no es una respuesta.
El arreglo, y su costo honesto
Mete los archivos woff2 en el repo y cámbiate a next/font/local. Así los bytes de la fuente son un input tuyo, versionado en git, y tu build hace cero llamadas de red por tipografía.
El costo es real, y no voy a fingir lo contrario:
- Las actualizaciones son tuyas. Se acabaron las mejoras gratis cuando Google republica una familia. Ese es justamente el punto — pero ahora es tu trabajo.
- Tienes que hacer subsetting — y probablemente ya estás enviando más de lo que crees.
subsets: ["latin"]no filtra lo que se descarga. Nunca llega a la URLcss2;loader.jsse lo pasa afindFontFilesInCsscomosubsetsToPreload, así que solo decide qué archivos reciben un hint de preload. Cada@font-facede la respuesta de Google se descarga y se emite. Launch declarasubsets: ["latin"]en las 8 familias y aun así emite 35 archivos woff2, de los cuales 9 llevan el marcador de preload.pen el nombre del archivo. Así que meter las fuentes al repo es una oportunidad de recortar charsets de verdad (pyftsubsetde fonttools) — pero si copias los woff2 directo desde.next/static/mediano cambiaste nada del peso. - Peso del repo. Esta es la parte que escala mal en mi caso. Entre los cinco templates que vendo, los loaders declaran 51 familias de Google:
folio-template/src/app/layout.tsx12,lens-template/src/app/fonts.ts6,levain-template/src/app/fonts.ts8,launch-template/src/lib/fonts.ts8,booking-template/src/lib/fonts.ts17. Eso se expande fuerte apenas combinas pesos, estilos y subsets: un build limpio del template launch emite 35 archivos woff2 en.next/static/media, y booking emite 92 (ls .next/static/media | grep -c woff2).
Así que la versión honesta es: mete al repo las fuentes que realmente renderizas, y borra el resto. Un selector de temas que ofrece 17 familias son 17 dependencias HTTP en tiempo de build, y cualquiera de ellas puede tirarte el deploy con un 404 un martes cualquiera.
El build que falló aquí era el de Launch — demo en vivo en https://launch.violettadev.com.
Cómo detectarlo la próxima vez
- Que pase en local no es una señal. Cuando tu máquina pasa y CI falla en el mismo commit, la explicación por defecto es que muestreaste la red en dos momentos distintos — no que una de las máquinas esté rota. Vuelve a correr el build que falló antes de salir a cazar una causa.
- Busca 404 de fuentes en tu log de build incluso con exit 0. Significan que los reintentos ganaron esta vez. Mismo fetch, misma fragilidad, a un intento de un deploy en rojo.
- Trata cada fetch en tiempo de build como una dependencia. Fuentes, blobs de wasm, schemas remotos, pulls del CMS en tiempo de build. Anota qué llamadas de red hace tu build. La mayoría de la gente no puede responder esa pregunta sobre su propio proyecto, y yo tampoco podía hasta que mi build me lo dijo a volumen de 404.