Notifio is a desktop app that watches rental search pages and tells you when something new appears. The website in front of it is a Next.js app with 38 routes, and until yesterday it got two of its three typefaces from Google at build time.
Now it gets them from server/src/app/fonts/. The commit touched one source file:
-import { Space_Grotesk, DM_Sans } from "next/font/google"; +import localFont from "next/font/local"; -const spaceGrotesk = Space_Grotesk({ - subsets: ["latin"], +const spaceGrotesk = localFont({ + src: "./fonts/space-grotesk-latin.woff2", variable: "--font-space-grotesk", - weight: ["300", "400", "500", "600", "700"], + weight: "300 700", display: "swap", preload: true, }); Plus the same five lines for DM Sans, plus two binaries: 22,320 bytes of Space Grotesk and 36,980 bytes of DM Sans.
Why
The comment I left above it is the whole bug report:
// Self-hosted latin variable fonts (Google Fonts). next/font/google in Turbopack // fails the build whenever Google serves a font URL containing "&". next/font/google is a build-time fetch. At build the loader asks Google's CSS endpoint what it should download, parses the stylesheet it gets back, and pulls the font files out of it. When one of those URLs came back with an & in it, the build fell over. Not the page, the build, which is the kind of failure that cannot be worked around at runtime and cannot be ignored.
I did not chase it to a minimal reproduction and file it. Two woff2 files is about four minutes of work, and it removes a network dependency from the build rather than fixing one. A deploy that reaches out to a third party for a font is a deploy that can fail for a reason that has nothing to do with the code being deployed, and this one did.
The weight list is not the same thing as the weight range
The part of that diff worth pausing on is not the import. It is this:
- weight: ["300", "400", "500", "600", "700"], + weight: "300 700", Both of these are correct, and they do not mean the same thing. The array is a request: give me these five weights. Against a variable font on Google's end it is a request for five named instances out of a continuous axis, and the loader returns a file per weight unless it decides to serve you the variable one.
The string is a declaration: this single file covers everything from 300 to 700, and the browser can interpolate anything in between. It is the font-weight: 300 700 descriptor in the generated @font-face, and it is only a true statement if the file you pointed at actually has a wght axis. If it did not, the browser would synthesise the weights by smearing the glyphs, and it would look like it.
So the line that used to be a shopping list is now an assertion about a binary sitting in the repo. Nothing checks it. If somebody replaces that file with a static 400-weight woff2, every bold heading on the site goes fake-bold and the build stays green.
What actually gets served
The nice thing about moving this into the repo is that the output becomes checkable from outside. Here is the production CSS, fetched just now:
@font-face{font-family:GeistMono;src:url(../media/GeistMono_Variable.p.3ms9vq719j3f8.woff2)format("woff2");font-display:swap;font-weight:100 900} @font-face{font-family:spaceGrotesk;src:url(../media/space_grotesk_latin-s.p.2cyn07wtgehh0.woff2)format("woff2");font-display:swap;font-weight:300 700} @font-face{font-family:spaceGrotesk Fallback;src:local(Arial);ascent-override:88.78%;descent-override:26.34%;line-gap-override:0.0%;size-adjust:110.84%} @font-face{font-family:dmSans;src:url(../media/dm_sans_latin-s.p.2_mbdogr7ni8i.woff2)format("woff2");font-display:swap;font-weight:300 700} @font-face{font-family:dmSans Fallback;src:local(Arial);ascent-override:94.36%;descent-override:29.49%;line-gap-override:0.0%;size-adjust:105.13%} Two things I want to point at.
First, the Fallback faces. Those are the entire reason to use next/font rather than writing four lines of @font-face yourself. display: swap means the browser paints Arial first and swaps in DM Sans when it arrives, and a naive swap moves every line of text on the page because Arial is not the same size. Those four overrides scale Arial to DM Sans' metrics so the swap happens without the layout jumping. size-adjust:105.13% is saying: render Arial 5% larger and it will occupy the space DM Sans is about to want.
I assumed this was a next/font/google feature and that self-hosting would cost me it. It is not. next/font/local reads the metrics out of the file I gave it and computes the same overrides.
Second, the hashed filenames with no extension-level versioning, which is what lets these be served as:
cache-control: public,max-age=31536000,immutable A year, immutable, on a URL that changes whenever the bytes change.
The content lengths on the wire are 22,320 and 36,980, byte for byte the files in the repo. No subsetting beyond the latin subset I downloaded, no transformation.
The third font never had this problem
There are three families on the site, and only two of them moved:
<html lang="en" className={`${GeistMono.variable} ${spaceGrotesk.variable} ${dmSans.variable}`}> GeistMono comes from the geist npm package, so it has always been a local file. It was never going to break on an ampersand, because nothing ever asked Google for it. It is also 71,368 bytes, which is more than the two I spent the afternoon on put together.
That is the actual lesson about dependency risk and it is slightly humbling: the font I never thought about was the one with the right delivery mechanism and the largest payload, and I would not have looked at either number if the build had not broken.
The three land in Tailwind v4 through variables rather than class names:
@theme { --font-sans: var(--font-dm-sans), ui-sans-serif, system-ui, sans-serif; --font-mono: var(--font-geist-mono), ui-monospace, monospace; --font-display: var(--font-space-grotesk), ui-sans-serif, system-ui, sans-serif; } So font-sans is DM Sans, font-display is Space Grotesk on headings, and the className on <html> is what puts the three CSS variables in scope. Change the import and every page follows, which is why this was a one-file commit.
The thing I found while writing this
I set preload: true on both. I then went to check it had survived the move, and the home page's HTML has no font preload in it at all:
$ curl -s https://notifio.app | grep -o '<link rel="preload"[^>]*>' <link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/2v_6snws2bthg.js"/> One preload, for a script. On a guides page, all three fonts are there:
<link rel="preload" href="/_next/static/media/GeistMono_Variable.p.3ms9vq719j3f8.woff2" as="font" crossorigin="" type="font/woff2"/> <link rel="preload" href="/_next/static/media/dm_sans_latin-s.p.2_mbdogr7ni8i.woff2" as="font" crossorigin="" type="font/woff2"/> <link rel="preload" href="/_next/static/media/space_grotesk_latin-s.p.2cyn07wtgehh0.woff2" as="font" crossorigin="" type="font/woff2"/> Same layout, same fonts, same flag. The difference is in the response headers of the page that appeared to be missing them:
$ curl -sI https://notifio.app/ | grep -i '^link' link: </_next/static/media/GeistMono_Variable...woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2", </_next/static/media/dm_sans_latin-s...woff2>; rel=preload; as="font"; ... The preloads are there. They are just in an HTTP Link header instead of in the markup.
Why those pages and not the others is something I had already written about without realising it connected here. Three of the 38 routes render per request and the other 35 are prerendered, and the reason is that a price is on screen: the home page, pricing and download resolve the visitor's currency from their request, so they cannot be baked at build time.
For a prerendered page the HTML exists already, so the preload tags are simply in it. For a page streamed per request, the headers go out before the body does, so the preload can be sent earlier as a header than it could ever appear as a tag. The three pages that render slowest get their font hints first.
I have no evidence this was anybody's deliberate design for my benefit, and I am not going to pretend I planned it. But the next time I am tempted to grep the HTML for a preload tag and declare a feature broken, I will check the headers first.
What I would take from this
Treat a build-time fetch as a dependency with a failure mode, because that is what it is. The fix for ours was to turn a network call into two files, and the files are smaller than the problem was.
And when you move a font in-house, check what you lose. I expected to lose the metric-matched fallback and the hashed immutable URL, and lost neither. What I did give up is automatic subsetting: the subsets: ["latin"] line is now a decision I made once with a download, not a parameter the build re-reads. If this site ever needs Greek, nothing will tell me. It will just render in Arial.
Notifio itself is at notifio.app, and the long-form pages where all this body text actually matters are under guides and alerts.


