next/font/google scarica i file woff2 da fonts.gstatic.com a build time. Quando Google ha ruotato l'hash di un file, il mio deploy ha iniziato a fallire con dei 404 che in locale non riuscivo a riprodurre, perché la stessa fetch sulla mia macchina era già andata a buon fine un secondo prima. Il fix: portare i font dentro il repo e usare next/font/local, così quei byte diventano un input che controlli tu.
Il sintomo
Un deploy è fallito due volte. Nell'app non era cambiato niente — stesso commit, stesso lockfile, stesso Node.
Error while requesting resource
Received response with status 404 ...
Module not found: Can't resolve '@vercel/turbopack-next/internal/font/google/font'Quell'ultima riga compariva 21 volte. Il 404 arrivava da fonts.gstatic.com.
Cosa avevo dato per scontato
Ho dato per scontato di aver rotto qualcosa nel container di deploy. Risoluzione delle dipendenze sbagliata, un layer Docker fatto male, un proxy che si mangia le richieste. Perché lo stesso comando sulla mia macchina — next build, Next.js 16.2.10 — buildava benissimo. Due volte. Verde.
È lì la trappola. «In locale builda» sembrava la prova che il container fosse rotto. Non era la prova di niente.
Perché era sbagliato
next/font/google non è un link a una CDN a runtime. È un downloader a build time. La documentazione di Next, che viaggia dentro il package in node_modules/next/dist/docs/01-app/03-api-reference/02-components/font.md:13, lo dice chiaro e tondo:
You can also conveniently use all Google Fonts. CSS and font files are downloaded at build time and self-hosted with the rest of your static assets. No requests are sent to Google by the browser.
Nessuna richiesta parte dal browser. Le richieste partono dal tuo build. Ognuna di esse è un'occasione perché il build fallisca per ragioni che non c'entrano niente con il tuo codice.
Ecco il meccanismo vero, preso dal loader distribuito in node_modules/next/dist/compiled/@next/font/dist/google/:
get-google-fonts-url.jscostruisce un URLcss2?family=...a partire dalle tue opzioni.fetch-css-from-google-fonts.jsfa una GET su quel CSS attraversofetch-resource.js, che ha hardcodato uno user agent Chrome 104 così Google risponde con woff2 invece che con ttf.find-font-files-in-css.jsestrae le vocisrc: url(...)— pathfonts.gstatic.comversionati e con hash completo.fetch-font-file.jsscarica ciascuno di essi e il build lo emette dentro.next/static/media.
Il punto 3 è dove muore l'ermeticità. Quegli URL non sono input stabili sotto il tuo controllo. Sono quello che Google ha messo nella risposta CSS nel momento esatto in cui il tuo build è girato.
E fetch-resource.js ha esattamente una policy per una risposta non-200:
if (res.statusCode !== 200) {
reject(new Error(errorMessage || `Request failed: ${url} (status: ${res.statusCode})`));
return;
}Il tutto avvolto in retry(fn, 3), cioè async-retry: il tentativo iniziale più tre, quattro richieste contro un URL che farà 404 quattro volte.
La finestra di rotazione
La richiesta che falliva puntava a .../inter/v20/UcCB3Fwr...woff2. Quando ho interrogato io stesso l'endpoint css2 per la stessa famiglia, Google restituiva .../inter/v20/UcCO3Fwr...woff2.
UcCB contro UcCO. Un carattere. Una rotazione di hash a metà volo: il CSS che avevo puntava a un path che non esisteva più.
E allora perché sulla mia macchina buildava?
La mia prima teoria era una cache stantia: il mio .next era precedente alla rotazione, quindi il build rigiocava una risposta buona dal disco mentre il container colpiva l'endpoint live. Ovvia, ordinata e — su questo setup — sbagliata.
Le cache del loader sono due Map in memoria create in cima a loader.js (cssCache, fontCache). Esistono per evitare che i compilatori client e server scarichino due volte lo stesso URL, e muoiono con il processo. La cache su disco di Turbopack sopravvivrebbe a un processo, ma experimental.turbopackFileSystemCacheForBuild è opt-in e disattivata di default in Next 16 (node_modules/next/dist/docs/01-app/03-api-reference/08-turbopack.md:204), e questo progetto non la imposta. Cosa contiene davvero .next/cache qui: .tsbuildinfo, due piccoli file di info ed esattamente una entry fetch-cache — 191.476 byte, content-type: application/octet-stream, magic byte wasm e un campo url che vale data:application/octet-stream;base64,…. Una data URI. Non un font, e nemmeno una chiamata di rete. Sul mio disco non c'era nessuna risposta di Google in cache da rigiocare.
Quindi entrambe le macchine hanno davvero chiesto a Google. L'hanno fatto in secondi diversi, fino a quattro volte ciascuna, e i tentativi dell'host sono capitati su una risposta buona.
.next l'ho rinominato e ho ribuildato — e l'host ha riprodotto il fallimento, il che sembrava una prova. Non lo era: la finestra brutta era ancora aperta, e lo stesso build pulito è passato un'ora dopo. Rinomina invece di cancellare comunque, non costa niente. Ma una singola riproduzione non ti vende un meccanismo; verifica che la cache che stai incolpando esista, prima di incolparla.
La parte più sgradevole: due gravità per un solo guasto
Sull'host i 404 venivano stampati e next build usciva comunque con 0. Dentro il container gli stessi 404 diventavano errori duri di risoluzione dei moduli di Turbopack e il build moriva.
Il caso exit-0 è retry.js che parla, non un fallimento tollerato. Ogni retry logga l'errore prima di riprovare:
onRetry(e, attempt) {
console.error(e.message + `\n\nRetrying ${attempt}/${retries}...`)
}Il che vuol dire che un log pieno di 404 dei font può significare «recuperato al tentativo 3» oppure «morto al tentativo 4», e le righe sono identiche in entrambi i casi. Leggi l'exit code, non il testo spaventoso.
Quello che il loader non farà in un build di produzione è degradare in silenzio — il suo catch si dirama in base all'ambiente, e solo dev si becca un fallback face:
if (isDev) {
// ...return a fallback @font-face instead of throwing
} else {
throw err
}Quel ramo è la vera trappola per il lavoro in locale. In next dev lo stesso URL morto ti restituisce un font di sistema local(...) con metriche di override, per cui il guasto che ti ammazza il deploy si presenta sulla tua macchina come una tipografia solo leggermente storta.
Next.js 16 rende Turbopack il bundler di default per next build (node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md:116), e il percorso Turbopack trasforma l'errore lanciato in moduli @vercel/turbopack-next/internal/font/google/font non risolvibili — 21 nel mio log.
La sequenza diagnostica
Rubatela, mi ha tolto di mezzo le supposizioni:
curl -Isull'URL woff2 esatto preso dall'errore. 404 → il file non c'è più, non è la tua rete.curlsull'endpointcss2?family=...per la stessa famiglia con uno user agent Chrome moderno. 200 con un hash diverso dentrosrc: url(...)→ rotazione confermata, non un disservizio.mv .next .next-bade ribuilda in locale. Un fallimento qui è una riproduzione dal vivo — ma prima di dare il merito alla cache, verifica che ce ne fosse una: la cache di build di Turbopack è opt-in, quindi un.nextstantio di solito è innocente.- Fai girare quel build pulito anche sull'host, due volte, a distanza di tempo. È il passo che tutti saltano, ed è quello che separa «il file non c'è più» da «il file non c'è stato per dieci minuti».
Un'ora dopo un build pulito è passato con zero 404. È questa la forma del bug: una finestra, non uno stato — ed è esattamente per questo che un build verde, su qualsiasi macchina, non è una risposta.
Il fix, e il suo costo onesto
Porta i file woff2 dentro il repo e passa a next/font/local. Così i byte dei font sono un input che controlli tu, committato in git, e il tuo build fa zero chiamate di rete per la tipografia.
Il costo è reale, e non ho intenzione di far finta del contrario:
- Gli aggiornamenti sono affar tuo. Niente più upgrade gratis quando Google ripubblica una famiglia. È esattamente il punto — ma adesso è compito tuo.
- Devi fare subsetting — e probabilmente stai già spedendo più di quanto pensi.
subsets: ["latin"]non filtra quello che viene scaricato. Non arriva mai all'URLcss2;loader.jslo passa afindFontFilesInCsscomesubsetsToPreload, quindi decide soltanto quali file ricevono un hint di preload. Ogni@font-facepresente nella risposta di Google viene scaricato ed emesso. Launch dichiarasubsets: ["latin"]su tutte e 8 le famiglie e continua a emettere 35 file woff2, di cui 9 portano il marcatore di preload.pnel nome. Portare i font nel repo è quindi l'occasione per tagliare davvero i charset (pyftsubset, di fonttools) — ma se copi i woff2 pari pari da.next/static/medianon hai cambiato niente in termini di peso. - Il peso del repo. È la parte che per me scala male. Sui cinque template che vendo, i loader dichiarano 51 famiglie 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. Numeri che esplodono una volta combinati pesi, stili e subset: un build pulito del template launch emette 35 file woff2 dentro.next/static/media, e booking ne emette 92 (ls .next/static/media | grep -c woff2).
La versione onesta, quindi, è: porta nel repo i font che renderizzi davvero, e cancella il resto. Un theme switcher che offre 17 famiglie sono 17 dipendenze HTTP a build time, e una qualsiasi di esse può farti 404 il deploy di martedì.
Il build che è fallito qui era quello di Launch — demo live su https://launch.violettadev.com.
Come beccarlo la prossima volta
- Il verde in locale non è un segnale. Quando la tua macchina passa e la CI fallisce sullo stesso commit, la spiegazione di default è che hai campionato la rete in due momenti diversi — non che una delle due macchine sia rotta. Rilancia il build fallito prima di andare a caccia di una causa.
- Cerca i 404 dei font nel log di build anche con exit 0. Vogliono dire che stavolta hanno vinto i retry. Stessa fetch, stessa fragilità, a un tentativo di distanza da un deploy rosso.
- Tratta ogni fetch a build time come una dipendenza. Font, blob wasm, schemi remoti, pull dal CMS a build time. Metti per iscritto quali chiamate di rete fa il tuo build. Quasi nessuno sa rispondere a questa domanda sul proprio progetto, e nemmeno io ci riuscivo finché il mio build non me l'ha detto a suon di 404.