o next/font/google descarrega ficheiros woff2 de fonts.gstatic.com em build time. Quando a Google trocou o hash de um ficheiro, o meu deploy começou a falhar com 404 que eu não conseguia reproduzir localmente, porque esse mesmo fetch já tinha corrido bem na minha máquina num segundo anterior. Correção: passar as fontes para o repositório e usar next/font/local, para que os bytes sejam um input teu.
O sintoma
Um deploy falhou duas vezes. Nada tinha mudado na app — mesmo commit, mesmo lockfile, mesmo Node.
Error while requesting resource
Received response with status 404 ...
Module not found: Can't resolve '@vercel/turbopack-next/internal/font/google/font'Essa última linha apareceu 21 vezes. O 404 vinha de fonts.gstatic.com.
O que eu assumi
Assumi que tinha partido alguma coisa no contentor de deploy. Resolução de dependências errada, uma camada de Docker má, um proxy a comer requests. Porque o mesmo comando na minha máquina — next build, Next.js 16.2.10 — compilava bem. Duas vezes. Verde.
É essa a armadilha. «Compila localmente» parecia prova de que o contentor estava partido. Não era prova de nada.
Porque é que isso estava errado
O next/font/google não é um link para um CDN em runtime. É um downloader em build time. A própria documentação do Next, que vem no pacote em node_modules/next/dist/docs/01-app/03-api-reference/02-components/font.md:13, di-lo sem rodeios:
Também podes usar comodamente todas as Google Fonts. O CSS e os ficheiros de fonte são descarregados em build time e alojados por ti juntamente com o resto dos teus assets estáticos. O browser não envia nenhum pedido à Google.
O browser não envia pedidos. Quem os envia é o teu build. Cada um deles é uma hipótese de o build falhar por razões que nada têm a ver com o teu código.
Eis o mecanismo real, tirado do loader que vem em node_modules/next/dist/compiled/@next/font/dist/google/:
- O
get-google-fonts-url.jsconstrói um URLcss2?family=...a partir das tuas opções. - O
fetch-css-from-google-fonts.jsfaz GET desse CSS através dofetch-resource.js, que tem hardcoded um user agent de Chrome 104 para a Google responder com woff2 em vez de ttf. - O
find-font-files-in-css.jsextrai as entradassrc: url(...)— caminhos defonts.gstatic.comversionados e com hash completo. - O
fetch-font-file.jsdescarrega cada um e o build emite-o para.next/static/media.
O passo 3 é onde morre a hermeticidade. Esses URLs não são inputs estáveis que tu controles. São aquilo que a Google pôs na resposta CSS no momento em que o teu build correu.
E o fetch-resource.js tem exatamente uma política para um não-200:
if (res.statusCode !== 200) {
reject(new Error(errorMessage || `Request failed: ${url} (status: ${res.statusCode})`));
return;
}Embrulhado em retry(fn, 3), que é o async-retry, portanto é a tentativa inicial mais três: quatro pedidos contra um URL que vai dar 404 quatro vezes.
A janela de rotação
O pedido que falhava era para .../inter/v20/UcCB3Fwr...woff2. Quando eu próprio fui buscar o endpoint css2 para essa mesma família, a Google estava a devolver .../inter/v20/UcCO3Fwr...woff2.
UcCB vs UcCO. Um caractere. Uma rotação de hash a meio do voo: o CSS que eu tinha apontava para um caminho de ficheiro que já não existia.
Então porque é que compilava na minha máquina?
A minha primeira teoria foi uma cache velha: o meu .next era anterior à rotação, portanto o build estava a repetir uma resposta boa a partir do disco enquanto o contentor batia no endpoint em direto. Óbvio, arrumadinho e — neste setup — errado.
As caches do loader são dois Map em memória criados no topo do loader.js (cssCache, fontCache). Existem para impedir que os compiladores de cliente e de servidor descarreguem o mesmo URL duas vezes, e morrem com o processo. A cache em disco do Turbopack sobreviveria a um processo, mas o experimental.turbopackFileSystemCacheForBuild é opt-in e está desligado por omissão no Next 16 (node_modules/next/dist/docs/01-app/03-api-reference/08-turbopack.md:204), e este projeto não o ativa. O que o .next/cache guarda aqui, na verdade: o .tsbuildinfo, dois ficheiros pequenos de info e exatamente uma entrada de fetch-cache — 191 476 bytes, content-type: application/octet-stream, magic bytes de wasm e um campo url com data:application/octet-stream;base64,…. Um data URI. Não é uma fonte, e nem sequer é uma chamada de rede. Não havia nenhuma resposta da Google em cache no meu disco para repetir.
Portanto ambas as máquinas perguntaram mesmo à Google. Perguntaram em segundos diferentes, até quatro vezes cada uma, e as tentativas do host calharam numa resposta boa.
Cheguei a renomear o .next e a recompilar — e o host reproduziu a falha, o que pareceu prova. Não era: a janela má ainda estava aberta, e esse mesmo build limpo passou uma hora depois. Renomeia em vez de apagar na mesma, não custa nada. Mas uma reprodução não te vende um mecanismo; confirma que a cache que estás a culpar existe antes de a culpares.
A parte mais feia: duas severidades para a mesma falha
No host, apareciam 404 e o next build saía na mesma com 0. Dentro do contentor, esses mesmos 404 tornavam-se erros duros de resolução de módulos do Turbopack e o build morria.
O caso do exit 0 é o retry.js a falar, não uma falha tolerada. Cada retry regista o erro antes de tentar outra vez:
onRetry(e, attempt) {
console.error(e.message + `\n\nRetrying ${attempt}/${retries}...`)
}O que quer dizer que um log cheio de 404 de fontes tanto pode significar «recuperou à terceira tentativa» como «morreu à quarta», e as linhas são idênticas em qualquer dos casos. Lê o exit code, não o texto assustador.
O que o loader não vai fazer num build de produção é degradar em silêncio — o seu catch ramifica consoante o ambiente, e só em dev é que há uma face de fallback:
if (isDev) {
// ...return a fallback @font-face instead of throwing
} else {
throw err
}Esse ramo é a verdadeira armadilha para o trabalho local. Em next dev o mesmo URL morto dá-te uma face do sistema via local(...) com métricas de override, portanto a falha que mata o teu deploy aparece na tua máquina como tipografia apenas ligeiramente diferente.
O Next.js 16 torna o Turbopack o bundler por omissão do next build (node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md:116), e o caminho do Turbopack transforma o erro lançado em módulos @vercel/turbopack-next/internal/font/google/font impossíveis de resolver — 21 deles no meu log.
A sequência de diagnóstico
Rouba-a, tirou-me o adivinhar do meio:
- Faz
curl -Iao URL woff2 exato que aparece no erro. 404 → o ficheiro desapareceu, não é a tua rede. - Faz
curlao endpointcss2?family=...da mesma família com um user agent de Chrome moderno. 200 com um hash diferente emsrc: url(...)→ rotação confirmada, não é uma falha de serviço. mv .next .next-bade recompila localmente. Uma falha aqui é uma reprodução em direto — mas antes de dares o crédito à cache, confirma que havia uma: a cache de build do Turbopack é opt-in, portanto um.nextvelho costuma ser inocente.- Corre esse build limpo também no host, duas vezes, com algum intervalo. É este que as pessoas saltam, e é o que separa «o ficheiro desapareceu» de «o ficheiro esteve em falta durante dez minutos».
Uma hora depois, um build limpo passou com zero 404. É esta a forma deste bug: uma janela, não um estado — que é precisamente por isso que um build verde, em qualquer máquina, não é uma resposta.
A correção, e o seu custo honesto
Passa os ficheiros woff2 para dentro do repositório e muda para next/font/local. Assim os bytes das fontes são um input teu, versionado em git, e o teu build faz zero chamadas de rede por causa da tipografia.
O custo é real, e não vou fingir o contrário:
- As atualizações passam a ser tuas. Acabaram-se as melhorias grátis quando a Google republica uma família. É esse mesmo o objetivo — mas agora é trabalho teu.
- Tens de fazer subsetting — e é provável que já estejas a enviar mais do que pensas.
subsets: ["latin"]não filtra aquilo que é descarregado. Nunca chega ao URLcss2; oloader.jspassa-o aofindFontFilesInCsscomosubsetsToPreload, portanto só decide que ficheiros recebem um hint de preload. Cada@font-faceda resposta da Google é descarregado e emitido. O Launch declarasubsets: ["latin"]nas 8 famílias e mesmo assim emite 35 ficheiros woff2, dos quais 9 levam o marcador de preload.pno nome do ficheiro. Portanto passar as fontes para o repositório é uma oportunidade de cortar mesmo charsets (pyftsubsetdo fonttools) — mas se copiares os ficheiros woff2 diretamente de.next/static/medianão mudaste nada quanto ao peso. - Peso do repositório. Esta é a parte que escala mal no meu caso. Nos cinco templates que vendo, os loaders declaram 51 famílias da 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. Isso expande-se muito assim que se combinam pesos, estilos e subsets: um build limpo do template launch emite 35 ficheiros woff2 para.next/static/media, e o booking emite 92 (ls .next/static/media | grep -c woff2).
Portanto a versão honesta é: passa para o repositório as fontes que realmente renderizas, e apaga o resto. Um seletor de temas que oferece 17 famílias são 17 dependências HTTP em build time, e qualquer uma delas pode dar 404 no teu deploy numa terça-feira.
O build que falhou aqui era o do Launch — demo em direto em https://launch.violettadev.com.
Como apanhá-lo da próxima vez
- Verde em local não é sinal. Quando a tua máquina passa e o CI falha no mesmo commit, a explicação por omissão é que amostraste a rede em dois momentos diferentes — não que uma das máquinas esteja partida. Volta a correr o build que falhou antes de ires à caça de uma causa.
- Procura 404 de fontes no log do build mesmo com exit 0. Querem dizer que os retries ganharam desta vez. Mesmo fetch, mesma fragilidade, a uma tentativa de um deploy vermelho.
- Trata cada fetch em build time como uma dependência. Fontes, blobs de wasm, schemas remotos, pulls do CMS em build time. Escreve numa lista que chamadas de rede é que o teu build faz. A maior parte das pessoas não consegue responder a essa pergunta sobre o próprio projeto, e eu também não conseguia até o meu build mo dizer em volume de 404.