If next build fails in next/font/google, re-run it before debugging anything. That was the rule I wrote down for this error on 28 September, and it lasted exactly one week. On 5 October, the production build on AWS CodeBuild failed with the same error on every run, and re-running stopped changing anything.
The project is a client's Next.js 16.3.6 site. This is not about a font failing to show up on the page. What stops is the build itself.
Twice, a re-run was enough
I first saw the error on 28 September, while I was verifying the upgrade to Next.js 16.3.6 on my laptop. One next build failed in next/font/google with the message "queries have exactly one entry", then passed when I ran it again. I filed it as a known flake tracked in vercel/next.js#99114, and that is where the rule came from.
On 1 October at about 19:02 WIB, the production build failed, even though the same tree had built fine on staging at 18:58 WIB, four minutes earlier. My AWS profile cannot read CodeBuild or CloudWatch, so the log was only readable by the client's devops engineer. Pointing to that lesson, I judged the failure most likely transient and retriggered the build with an empty commit. The rebuild passed at 19:15 WIB, and that day's lesson reinforced the old rule. The log for the 1 October failure itself was never read.
What issue #99114 describes
The issue was opened on 23 September 2026. The reporter writes that Google Fonts sometimes answers the css2?family=... request with /l/font?kit=... URLs instead of /s/.../*.woff2, and that when it does, next build fails on both bundlers. In their sampling with Next's User-Agent, about 1 in 60 responses came back in that shape, and in CI it looks like a random flake that goes away on rebuild. According to the issue, the same error reproduces on 16.3.6 and 16.2.12, and on Turbopack the message reads like this:
Error while looking up import map: next/font/google queries have exactly one entryThe cause the reporter gives sits in Turbopack. The JSON-serialized options are passed through QString::from, which parses its input as a query string, and the & in &skey=...&v=v20 in those /l/font?kit= URLs splits it into several pairs. Read with that explanation, the "queries" in the error message point at that internal query string, not at the application code. The same issue also notes that Next's built-in retry() covers only transport errors and non-200 responses, while this response is a 200, so every CI build that fetches fonts fresh carries its own chance of failing.
5 October: the re-run failed too
On 5 October at 12:00 WIB, the production build failed with COMMAND_EXECUTION_ERROR and the message "Error while executing command: pnpm build. Reason: exit status 1". Nothing was deployed, and production kept serving the 1 October build. A clean local build of the same commit, in a temporary worktree with pnpm install --frozen-lockfile followed by pnpm build, passed with exit 0, so the problem sat in the CodeBuild environment, not in the code. I could not read the log myself: my IAM user got AccessDenied on codepipeline:GetPipelineState, codebuild:BatchGetBuilds and logs:DescribeLogGroups. My strongest suspect was the next/font/google download at build time, but without the log it was still a guess.
The log finally came from the client's devops engineer at 13:03 and 13:09 WIB. According to that log, the 12:00 build and the re-run they started themselves at 13:05 both died in next build with "next/font/google queries have exactly one entry": 28 errors from NextFontGoogleFontFileReplacer, with an import trace from app/layout.tsx. It was the same issue #99114 error I had seen on my laptop.
Going by the issue's explanation, that meant Google was now answering CodeBuild with font URLs Turbopack cannot parse, and every production build was blocked until the fonts stopped coming from Google. The 1 in 60 figure above comes from the reporter's sampling. On CodeBuild that day, the bad answer came back on every run, so "re-run before debugging" was no longer enough.
The fonts become files in the repo
I wrote the new rule down that same day: a build must not depend on a third-party download, the fonts are now files in the repo, and next/font/google must never come back.
Worth keeping in mind: next/font/google already serves fonts from your own domain. The fonts are downloaded at build time, so visitors' browsers never ask Google for anything. The dependency lives in the build, not on the page.
Before the fix, app/layout.tsx imported three families from next/font/google with subsets: ["latin"]: two sans families at weights 400, 500, 600 and 700, and one monospace family at 400, 500 and 600. I did not download the replacement files from Google again. I took 16 woff2 files, 333 KB in total, from production's /_next/static/media, so their bytes and content hashes match what was live exactly. Three of them are the Latin files, one per family, and the other 13 cover other alphabets (3 + 13 = 16). In other words, subsets only decides which files get preloaded, not which alphabets get built.
The Latin files go through next/font/local. The comment in the layout explains why, and also why the unicode-range has to be there. Simplified to a single family:
// app/layout.tsx, simplified to one family. Family names, paths and variable
// names are anonymized; the English comments below are quoted from the
// original file.
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",
},
],
});The weights are written as a range, "400 700" for the sans families and "400 600" for the monospace one, because each file is a variable font, and the ranges are no wider than what was asked for before. The browser treats a face without a unicode-range as covering every character, so Google's Latin range is what sends a letter like "ế" to the Vietnamese file.
The other thirteen files are registered in a stylesheet as plain @font-face rules under the real family names, together with three Arial-based fallback faces carrying the metrics next/font/google used to generate. adjustFontFallback is switched off in next/font/local, and the :root stacks set the order: the Latin face, the other alphabets, then the fallback.
/* Simplified. "Body" stands in for the real family name, and the 13
@font-face rules for other alphabets are not shown. The English comment
below is quoted from the original file. */
/* 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";
}Same widths, different glyphs
Before committing, 796 tests, tsc and eslint were clean, and pnpm build passed both in the working tree and in a clean export of exactly the tree about to be committed, which I made with a temporary index and git archive. The export is needed because a working tree can carry files that never make it into the commit. The build output held 16 fonts and not a single fonts.googleapis or gstatic string.
I proved the look in a browser, not by reading CSS. In headless Chrome, I compared the local build with production on two pages using 35 probe samples: three families, weights including clamped ones, synthetic italic and seven alphabets. The final result was identical down to the pixel, with the same three preload headers and zero requests to Google.
Along the way, that comparison caught one difference that widths could not show. Without a unicode-range, Chrome composed "ế" from "e" plus loose marks: 22 glyphs instead of 21, and different pixels. What caught it was a screenshot of the probe block compared by hash, plus glyph counts from the CDP command CSS.getPlatformFontsForNode. Once the Latin faces got Google's range, the result was identical.
Six guards, then production
The fix added one test file with six guards, with no new package and without touching next.config.ts or middleware.ts. The guards make sure there is no next/font/google import in the application code, every requested file exists with no stray ones, the Latin files only go through next/font/local and stay limited to the Latin range, the stack order holds, and the files are real woff2 rather than an error page saved as .woff2:
// Simplified from the guard tests; fontFiles is the list of woff2 paths in the 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");
}
});The commit held 19 files, exactly the tested tree: 16 woff2 files plus the layout, the stylesheet and the test file. After the merge into main, the CodeBuild build passed on the first try and production was live seven minutes after the push. The font probe gave the same pixel hash as the old build, the three Latin files were preloaded, and there were no requests to Google. The self-hosted fonts went live at 13:48 WIB, one hour and 48 minutes after the failed 12:00 build, and that also ended the Google Fonts build failures.
One note about logs. A day later, my IAM user was given read access to CodePipeline, CodeBuild and CodeDeploy. It worked for state, executions, builds and deployments, but the build log text was still denied, because that text lives in CloudWatch Logs.
A rule that depends on someone else
The "re-run first" rule was not wrong for the situation it described. The trouble is that it depends on how often someone else fails, and that number is not mine. Its replacement does not rely on luck: a build must not depend on a third-party download, and the guard test rejects next/font/google if anyone ever puts it back.
Two things stay open. The cause of the 1 October failure was never read from a log, so I cannot confirm it was the same error, even though my fix commit's message says it was. And what is in place is a workaround on the project's side, not a fix in Next.js.