Kalau next build gagal di next/font/google, jalankan ulang dulu sebelum men-debug. Itu aturan yang saya tulis pada 28 September untuk error ini, dan umurnya tepat seminggu. Pada 5 Oktober, build produksi di AWS CodeBuild gagal dengan error yang sama di setiap run, dan menjalankan ulang tidak lagi mengubah apa pun.
Proyeknya situs Next.js 16.3.6 milik klien. Ini bukan soal font yang tidak tampil di halaman. Yang berhenti adalah build-nya sendiri.
Dua kali, menjalankan ulang sudah cukup
Error ini pertama kali saya lihat pada 28 September, saat saya memverifikasi upgrade ke Next.js 16.3.6 di laptop. Satu next build gagal di next/font/google dengan pesan "queries have exactly one entry", lalu lulus ketika diulang. Saya mencatatnya sebagai flake yang sudah dikenal di isu vercel/next.js#99114, dan dari situlah aturan tadi berasal.
Pada 1 Oktober sekitar 19.02 WIB, build produksi gagal, padahal tree yang sama lulus build di staging pukul 18.58 WIB, empat menit sebelumnya. Profil AWS saya tidak bisa membaca CodeBuild maupun CloudWatch, jadi lognya hanya bisa dibaca devops di sisi klien. Dengan merujuk pelajaran tadi, saya menilai kegagalan itu kemungkinan besar sementara, lalu memicu ulang build lewat commit kosong. Build ulangnya lulus pukul 19.15 WIB, dan pelajaran hari itu ikut memperkuat aturan lama. Log kegagalan 1 Oktober itu sendiri tidak pernah dibaca.
Yang dijelaskan isu #99114
Isu itu dibuka 23 September 2026. Pelapornya menulis bahwa Google Fonts kadang menjawab request css2?family=... dengan URL /l/font?kit=... alih-alih /s/.../*.woff2, dan saat itu terjadi next build gagal di kedua bundler. Dari sampel mereka dengan User-Agent milik Next, kira-kira satu dari 60 jawaban berbentuk begitu, dan di CI gejalanya mirip flake acak yang hilang saat rebuild. Menurut isu itu, error yang sama muncul di 16.3.6 dan 16.2.12, dan di Turbopack pesannya berbunyi begini:
Error while looking up import map: next/font/google queries have exactly one entryPenyebab yang ditulis pelapor ada di Turbopack. Opsi yang sudah diserialisasi menjadi JSON dilewatkan ke QString::from, yang mengurainya sebagai query string, lalu tanda & di &skey=...&v=v20 pada URL /l/font?kit= tadi memecahnya menjadi beberapa pasangan. Dengan penjelasan itu, kata "queries" di pesan error-nya menunjuk ke query string internal, bukan ke kode aplikasi. Isu yang sama juga menulis bahwa retry() bawaan Next hanya menangani error transport dan jawaban non-200, sedangkan jawaban ini berstatus 200, jadi setiap build CI yang mengambil font baru punya peluang gagalnya sendiri.
5 Oktober: diulang pun tetap gagal
Pada 5 Oktober pukul 12.00 WIB, build produksi gagal dengan COMMAND_EXECUTION_ERROR dan pesan "Error while executing command: pnpm build. Reason: exit status 1". Tidak ada yang ter-deploy, dan produksi tetap menyajikan build 1 Oktober. Build lokal yang bersih dari commit yang sama, di worktree sementara dengan pnpm install --frozen-lockfile lalu pnpm build, lulus dengan exit 0, jadi masalahnya ada di lingkungan CodeBuild, bukan di kode. Lognya tidak bisa saya baca sendiri: user IAM saya mendapat AccessDenied untuk codepipeline:GetPipelineState, codebuild:BatchGetBuilds, dan logs:DescribeLogGroups. Tersangka terkuat saya adalah unduhan next/font/google saat build, tapi tanpa log, itu masih dugaan.
Log akhirnya dikirim devops di sisi klien pukul 13.03 dan 13.09 WIB. Menurut log itu, build pukul 12.00 dan re-run yang mereka jalankan sendiri pukul 13.05 sama-sama mati di next build dengan "next/font/google queries have exactly one entry": 28 error dari NextFontGoogleFontFileReplacer, dengan jejak impor dari app/layout.tsx. Itu error isu #99114 yang sama dengan yang muncul di laptop.
Dengan penjelasan isu itu, artinya Google sedang menjawab CodeBuild dengan URL font yang tidak bisa diurai Turbopack, dan setiap build produksi terblokir sampai font berhenti diambil dari Google. Angka satu dari 60 tadi berasal dari sampel pelapor. Di CodeBuild hari itu, jawaban buruk datang di setiap run, jadi "jalankan ulang sebelum men-debug" tidak lagi cukup.
Font menjadi berkas di repo
Aturan barunya saya tulis hari itu juga: build tidak boleh bergantung pada unduhan pihak ketiga, font sekarang berupa berkas di repo, dan next/font/google tidak boleh dipakai lagi.
Perlu diingat, next/font/google sebenarnya sudah menyajikan font dari domain sendiri. Fontnya diunduh saat build, jadi browser pengunjung tidak pernah meminta apa pun ke Google. Ketergantungannya ada di build, bukan di halaman.
Sebelumnya, app/layout.tsx mengimpor tiga keluarga dari next/font/google dengan subsets: ["latin"]: dua keluarga sans berbobot 400, 500, 600, 700 dan satu monospace berbobot 400, 500, 600. Berkas penggantinya tidak saya unduh ulang dari Google. Saya ambil 16 berkas woff2, total 333 KB, dari /_next/static/media produksi, jadi byte dan content hash-nya sama persis dengan yang sedang tayang. Tiga di antaranya berkas Latin, satu per keluarga, dan 13 sisanya untuk alfabet lain (3 + 13 = 16). Artinya subsets cuma menentukan berkas yang di-preload, bukan alfabet yang ikut dibangun.
Berkas Latin masuk lewat next/font/local. Komentar di layout menjelaskan alasannya, sekaligus kenapa unicode-range-nya wajib ada. Disederhanakan menjadi satu keluarga:
// app/layout.tsx, disederhanakan menjadi satu keluarga. Nama keluarga, path,
// dan nama variabel sudah disamarkan; komentar berbahasa Inggris di bawah
// dikutip dari berkas aslinya.
import localFont from "next/font/local";
// next/font/local takes the Latin file of each family, because that is what
// earns it a preload header and a hashed, immutable URL. Never go back to
// next/font/google: it makes every build depend on a download from Google,
// and that download broke production builds in October 2026.
//
// The unicode-range is the one Google's stylesheet gave the Latin file, and it
// has to stay. Without it the browser asks the Latin file first for every
// character, and for a letter like "ế" it builds the glyph from "e" plus loose
// accents instead of fetching the Vietnamese file that holds the drawn one.
// It is written out three times because next/font only accepts literals.
const bodyLatin = localFont({
src: "./typefaces/body-latin.woff2",
weight: "400 700",
style: "normal",
variable: "--font-body-latin",
display: "swap",
adjustFontFallback: false,
declarations: [
{
prop: "unicode-range",
value:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD",
},
],
});Bobot ditulis sebagai rentang, "400 700" untuk sans dan "400 600" untuk monospace, karena setiap berkas adalah variable font, dan rentangnya tidak lebih lebar dari yang dulu diminta. Face tanpa unicode-range dianggap browser mencakup semua karakter, jadi rentang Latin versi Google inilah yang membuat huruf seperti "ế" diambil dari berkas Vietnam.
Tiga belas berkas lainnya didaftarkan di sebuah stylesheet sebagai @font-face biasa dengan nama keluarga aslinya, bersama tiga face fallback berbasis Arial dengan metrik yang dulu dibuat next/font/google. adjustFontFallback di next/font/local dimatikan, dan stack di :root menyusun urutannya: face Latin, alfabet lain, lalu fallback.
/* Disederhanakan. "Body" mewakili nama keluarga aslinya, dan 13 aturan
@font-face untuk alfabet lain tidak ditampilkan. Komentar berbahasa Inggris
dikutip dari berkas aslinya. */
/* The "Fallback" faces are Arial resized to each family's metrics (the numbers
* next/font/google generated), so swapping the web font in does not move the
* layout. */
@font-face {
font-family: "Body Fallback";
src: local("Arial");
ascent-override: 90.44%;
descent-override: 22.52%;
line-gap-override: 0.0%;
size-adjust: 107.12%;
}
:root {
--font-body: var(--font-body-latin, "Body"), "Body", "Body Fallback";
}Lebar sama, glyph berbeda
Sebelum commit, 796 tes, tsc, dan eslint bersih, dan pnpm build lulus di working tree maupun di ekspor bersih dari tree yang persis akan di-commit, yang saya buat lewat index sementara dan git archive. Ekspor itu perlu karena working tree bisa membawa berkas yang tidak ikut ter-commit. Output build-nya berisi 16 font tanpa satu pun string fonts.googleapis atau gstatic.
Tampilannya saya buktikan di browser, bukan dengan membaca CSS. Di headless Chrome, build lokal saya bandingkan dengan produksi di dua halaman memakai 35 sampel probe: tiga keluarga, bobot termasuk yang di-clamp, italic sintetis, dan tujuh alfabet. Hasil akhirnya identik sampai ke piksel, dengan tiga header preload yang sama dan nol request ke Google.
Di tengah jalan, pembandingan itu sempat menangkap satu perbedaan yang tidak kelihatan dari lebar. Tanpa unicode-range, Chrome merangkai "ế" dari "e" ditambah tanda yang lepas: 22 glyph, bukan 21, dan pikselnya berbeda. Yang menangkapnya adalah screenshot blok probe yang dibandingkan hash-nya, ditambah jumlah glyph dari perintah CDP CSS.getPlatformFontsForNode. Begitu face Latin diberi rentang versi Google, hasilnya identik.
Enam penjaga, lalu produksi
Perbaikan ini menambah satu berkas tes berisi enam penjaga, tanpa paket baru dan tanpa menyentuh next.config.ts maupun middleware.ts. Penjaganya memastikan tidak ada impor next/font/google di kode aplikasi, semua berkas yang diminta ada tanpa berkas liar, berkas Latin hanya lewat next/font/local dan dibatasi rentang Latin, urutan stack terjaga, dan berkasnya woff2 asli, bukan halaman error yang tersimpan sebagai .woff2:
// Disederhanakan dari tes penjaga; fontFiles adalah daftar path woff2 di repo.
it("holds real woff2 files, not a saved error page", () => {
for (const file of fontFiles) {
const head = readFileSync(file).subarray(0, 4);
expect(head.toString("latin1"), file).toBe("wOF2");
}
});Commit-nya 19 berkas, persis tree yang dites: 16 woff2 ditambah layout, stylesheet, dan berkas tes. Setelah di-merge ke main, build CodeBuild lulus di percobaan pertama dan produksi tayang tujuh menit setelah push. Probe font memberi hash piksel yang sama dengan build lama, tiga berkas Latin di-preload, dan tidak ada request ke Google. Font self-hosted tayang pukul 13.48 WIB, satu jam 48 menit setelah build yang gagal pukul 12.00, dan itu sekaligus mengakhiri kegagalan build karena Google Fonts.
Satu catatan soal log. Sehari kemudian, user IAM saya diberi akses baca ke CodePipeline, CodeBuild, dan CodeDeploy. Akses itu bekerja untuk status, eksekusi, build, dan deployment, tapi teks log build tetap ditolak, karena teksnya tinggal di CloudWatch Logs.
Aturan yang bergantung pada pihak lain
Aturan "ulang dulu" tidak salah untuk keadaan yang ia gambarkan. Masalahnya, aturan itu bergantung pada seberapa sering pihak lain gagal, dan angka itu bukan milik saya. Penggantinya tidak bergantung pada keberuntungan: build tidak boleh bergantung pada unduhan pihak ketiga, dan tes penjaga menolak next/font/google kalau suatu hari ada yang memasangnya lagi.
Dua hal tetap terbuka. Penyebab kegagalan 1 Oktober tidak pernah terbaca dari log, jadi saya tidak bisa memastikan itu error yang sama, walaupun pesan commit perbaikan saya menyebutnya begitu. Dan yang terpasang adalah jalan memutar di sisi proyek, bukan perbaikan di Next.js.