next/font/google télécharge des fichiers woff2 depuis fonts.gstatic.com au moment du build. Quand Google a fait tourner le hash d'un fichier, mon deploy s'est mis à échouer avec des 404 impossibles à reproduire en local, parce que le même fetch avait déjà réussi sur ma machine une seconde plus tôt. Le correctif : embarquer les polices dans le repo et utiliser next/font/local, pour que ces octets soient une entrée qui vous appartient.
Le symptôme
Un deploy a échoué deux fois. Rien n'avait changé dans l'app — même commit, même lockfile, même Node.
Error while requesting resource
Received response with status 404 ...
Module not found: Can't resolve '@vercel/turbopack-next/internal/font/google/font'Cette dernière ligne apparaissait 21 fois. Le 404 venait de fonts.gstatic.com.
Ce que j'ai supposé
J'ai supposé que j'avais cassé quelque chose dans le conteneur de deploy. Mauvaise résolution de dépendances, une couche Docker foireuse, un proxy qui mange les requêtes. Parce que la même commande sur ma machine — next build, Next.js 16.2.10 — buildait très bien. Deux fois. Vert.
C'est là le piège. « Ça build en local » avait l'air d'une preuve que le conteneur était cassé. Ça ne prouvait rien du tout.
Pourquoi c'était faux
next/font/google n'est pas un lien CDN au runtime. C'est un téléchargeur au build. La doc de Next elle-même, livrée dans le package à node_modules/next/dist/docs/01-app/03-api-reference/02-components/font.md:13, le dit noir sur blanc :
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.
Aucune requête n'est envoyée par le navigateur. Les requêtes sont envoyées par votre build. Chacune d'elles est une occasion pour le build d'échouer pour des raisons qui n'ont rien à voir avec votre code.
Voici le mécanisme réel, tiré du loader livré dans node_modules/next/dist/compiled/@next/font/dist/google/ :
get-google-fonts-url.jsconstruit une URLcss2?family=...à partir de vos options.fetch-css-from-google-fonts.jsfait un GET sur ce CSS viafetch-resource.js, qui code en dur un user agent Chrome 104 pour que Google réponde en woff2 plutôt qu'en ttf.find-font-files-in-css.jsextrait les entréessrc: url(...)— des cheminsfonts.gstatic.comversionnés et intégralement hashés.fetch-font-file.jstélécharge chacun d'eux et le build les émet dans.next/static/media.
L'étape 3, c'est là que l'herméticité meurt. Ces URLs ne sont pas des entrées stables que vous contrôlez. Ce sont celles que Google a mises dans la réponse CSS au moment précis où votre build a tourné.
Et fetch-resource.js n'a qu'une seule politique face à un non-200 :
if (res.statusCode !== 200) {
reject(new Error(errorMessage || `Request failed: ${url} (status: ${res.statusCode})`));
return;
}Le tout emballé dans retry(fn, 3), c'est-à-dire async-retry : la tentative initiale plus trois, soit quatre requêtes contre une URL qui va renvoyer 404 quatre fois.
La fenêtre de rotation
La requête en échec visait .../inter/v20/UcCB3Fwr...woff2. Quand j'ai interrogé moi-même l'endpoint css2 pour la même famille, Google renvoyait .../inter/v20/UcCO3Fwr...woff2.
UcCB contre UcCO. Un caractère. Une rotation de hash en plein vol : le CSS que j'avais pointait vers un chemin de fichier qui n'existait plus.
Alors pourquoi ça buildait sur ma machine ?
Ma première théorie était un cache périmé : mon .next était antérieur à la rotation, donc le build rejouait une bonne réponse depuis le disque pendant que le conteneur tapait sur l'endpoint en direct. Évident, bien rangé, et — sur cette configuration — faux.
Les caches du loader sont deux Map en mémoire créées en haut de loader.js (cssCache, fontCache). Elles existent pour éviter que les compilateurs client et serveur ne téléchargent deux fois la même URL, et elles meurent avec le processus. Le cache sur disque de Turbopack survivrait à un processus, mais experimental.turbopackFileSystemCacheForBuild est opt-in et désactivé par défaut dans Next 16 (node_modules/next/dist/docs/01-app/03-api-reference/08-turbopack.md:204), et ce projet ne l'active pas. Ce que .next/cache contient réellement ici : .tsbuildinfo, deux petits fichiers d'info, et exactement une entrée fetch-cache — 191 476 octets, content-type: application/octet-stream, des magic bytes wasm, et un champ url valant data:application/octet-stream;base64,…. Une data URI. Pas une police, et même pas un appel réseau. Il n'y avait aucune réponse de Google en cache sur mon disque à rejouer.
Donc les deux machines ont bel et bien interrogé Google. Elles l'ont fait à des secondes différentes, jusqu'à quatre fois chacune, et les tentatives de l'hôte sont tombées sur une bonne réponse.
J'ai bien renommé .next et rebuildé — et l'hôte a reproduit l'échec, ce qui avait des airs de preuve. Ça n'en était pas : la mauvaise fenêtre était toujours ouverte, et le même build propre est passé une heure plus tard. Renommez plutôt que de supprimer, de toute façon, ça ne coûte rien. Mais une reproduction ne suffit pas à vous vendre un mécanisme ; vérifiez que le cache que vous accusez existe avant de l'accuser.
Le pire : deux gravités pour une seule faute
Sur l'hôte, les 404 s'affichaient et next build sortait quand même avec le code 0. Dans le conteneur, les mêmes 404 devenaient des erreurs dures de résolution de modules Turbopack et le build mourait.
Le cas exit-0, c'est retry.js qui parle, pas un échec toléré. Chaque retry logge l'erreur avant de réessayer :
onRetry(e, attempt) {
console.error(e.message + `\n\nRetrying ${attempt}/${retries}...`)
}Ce qui veut dire qu'un log plein de 404 de polices peut signifier « récupéré à la tentative 3 » ou « mort à la tentative 4 », et les lignes sont identiques dans les deux cas. Lisez le code de sortie, pas le texte effrayant.
Ce que le loader ne fera pas dans un build de production, c'est se dégrader en silence — son catch se ramifie selon l'environnement, et seul le mode dev a droit à une fonte de repli :
if (isDev) {
// ...return a fallback @font-face instead of throwing
} else {
throw err
}Cette branche est le vrai piège pour le travail en local. En next dev, la même URL morte vous donne une fonte système local(...) avec des métriques d'override, si bien que la faute qui tue votre deploy apparaît sur votre machine comme une typographie juste un peu de travers.
Next.js 16 fait de Turbopack le bundler par défaut de next build (node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md:116), et le chemin Turbopack transforme l'erreur levée en modules @vercel/turbopack-next/internal/font/google/font non résolvables — 21 dans mon log.
La séquence de diagnostic
Piquez-la, elle a enlevé la part de devinette :
curl -Isur l'URL woff2 exacte tirée de l'erreur. 404 → le fichier a disparu, ce n'est pas votre réseau.curlsur l'endpointcss2?family=...pour la même famille avec un user agent Chrome récent. 200 avec un hash différent danssrc: url(...)→ rotation confirmée, pas une panne.mv .next .next-badet rebuild en local. Un échec ici est une reproduction en direct — mais avant de mettre ça sur le dos du cache, vérifiez qu'il y en avait un : le cache de build de Turbopack est opt-in, donc un.nextpérimé est en général innocent.- Lancez aussi ce build propre sur l'hôte, deux fois, à un bon intervalle. C'est celle que les gens sautent, et c'est celle qui distingue « le fichier a disparu » de « le fichier a disparu pendant dix minutes ».
Une heure plus tard, un build propre est passé avec zéro 404. C'est la forme de ce bug : une fenêtre, pas un état — et c'est exactement pour ça qu'un build vert, sur n'importe quelle machine, n'est pas une réponse.
Le correctif, et son coût réel
Embarquez les fichiers woff2 dans le repo et passez à next/font/local. Les octets des polices deviennent alors une entrée qui vous appartient, versionnée dans git, et votre build ne fait plus aucun appel réseau pour la typographie.
Le coût est réel, et je ne vais pas prétendre le contraire :
- Les mises à jour vous incombent. Fini les upgrades gratuits quand Google republie une famille. C'est tout l'intérêt — mais c'est maintenant votre boulot.
- Vous devez faire du subsetting — et vous livrez probablement déjà plus que vous ne le croyez.
subsets: ["latin"]ne filtre pas ce qui est téléchargé. Ça n'atteint jamais l'URLcss2;loader.jsle passe àfindFontFilesInCssen tant quesubsetsToPreload, donc ça décide seulement quels fichiers reçoivent un hint de preload. Chaque@font-facede la réponse de Google est téléchargé et émis. Launch déclaresubsets: ["latin"]sur ses 8 familles et émet quand même 35 fichiers woff2, dont 9 portent le marqueur de preload.pdans le nom. Embarquer les polices est donc l'occasion de vraiment tailler dans les charsets (pyftsubset, de fonttools) — mais si vous copiez les woff2 directement depuis.next/static/media, vous n'avez rien changé au poids. - Le poids du repo. C'est la partie qui passe mal à l'échelle chez moi. Sur les cinq templates que je vends, les loaders déclarent 51 familles 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. Ça explose vite une fois les graisses, les styles et les subsets combinés : un build propre du template launch émet 35 fichiers woff2 dans.next/static/media, et booking en émet 92 (ls .next/static/media | grep -c woff2).
La version honnête, donc : embarquez les polices que vous affichez vraiment, et supprimez le reste. Un theme switcher qui propose 17 familles, ce sont 17 dépendances HTTP au build, et n'importe laquelle peut faire 404 sur votre deploy un mardi.
Le build qui a échoué ici était celui de Launch — démo en ligne sur https://launch.violettadev.com.
Comment le repérer la prochaine fois
- Un vert en local n'est pas un signal. Quand votre machine passe et que la CI échoue sur le même commit, l'explication par défaut est que vous avez échantillonné le réseau à deux moments différents — pas qu'une des machines est cassée. Relancez le build en échec avant de partir à la chasse à la cause.
- Cherchez les 404 de polices dans votre log de build même sur un exit 0. Ils veulent dire que les retries ont gagné cette fois-ci. Même fetch, même fragilité, à une tentative d'un deploy rouge.
- Traitez chaque fetch au build comme une dépendance. Polices, blobs wasm, schémas distants, pulls CMS au build. Notez quels appels réseau votre build effectue. La plupart des gens ne savent pas répondre à cette question sur leur propre projet, et moi non plus jusqu'à ce que mon build me le dise à coups de 404.