Open any page of our marketing site, for example the FAQ, and paste this into the console:

getComputedStyle(document.body).fontFamily 

You get this:

ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" 

That is Tailwind v4's default sans stack, untouched. Our typeface is Geist. It is not in that list, and it is not in any other list either:

new Set([...document.querySelectorAll("body *")].map(e => getComputedStyle(e).fontFamily)).size // 1 

One font stack, for every element on the page, and it is the operating system's.

Meanwhile the Network tab, filtered to Font, shows two woff2 files arriving on that same page load: 23,108 bytes and 29,288 bytes of Geist.

What we thought was configured

app/layout.tsx loads two families through next/font/google:

const geist = Geist({ subsets: ["latin"], display: "swap", variable: "--font-geist" }); const geistMono = Geist_Mono({ subsets: ["latin"], display: "swap", variable: "--font-geist-mono" }); 

and puts both variables on the body together with Tailwind's font-sans utility:

<body className={`${geist.variable} ${geistMono.variable} font-sans antialiased`}> 

Then app/globals.css is supposed to connect the two:

@import "tailwindcss"; @config "../tailwind.config.ts"; @theme { --font-family-sans: var(--font-geist), system-ui, sans-serif; --font-family-mono: var(--font-geist-mono), monospace; } 

Everything in that chain works except the last part. Tailwind v4's theme namespace for typefaces is --font-*, so the key that drives the font-sans utility is --font-sans. We wrote --font-family-sans, which is not a namespace v4 knows, so the declaration is not a theme key at all. It does not override anything, and it does not even survive into the build: grep the two delivered stylesheets, 107,598 bytes in total, for font-family-sans and you get zero hits.

What you do get is the default:

.font-sans{font-family:var(--font-sans)} 
--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", ... 

One word, family, is the whole bug. The --font-geist variable is defined correctly on the body, by the rule next/font emits:

.geist_6a0e6105-module__QVq-Ba__variable{--font-geist:"Geist", "Geist Fallback"} 

Nothing reads it.

The renderer's answer, not the stylesheet's

Computed styles are an argument about CSS. The question of which font actually painted the glyphs is answered by Chrome through CSS.getPlatformFontsForNode over the DevTools protocol, which reports the real typeface and how many glyphs it supplied:

page node family custom font? glyphs
/faq h1 .SF NS no 26
/faq first main p .SF NS no 149
/pricing h1 .SF NS no 7
/legal/privacy first main p .SF NS no 29
/tools/round-planner first main p .SF NS no 155

.SF NS is San Francisco, macOS's UI font. isCustomFont: false means no web font was involved. On Windows that row would say Segoe UI Variable, on Android Roboto. Every visitor gets their own operating system's font, which is a perfectly reasonable design decision that nobody on this project ever made.

You can see the same thing without a protocol client: DevTools, Elements, Computed, scroll to the bottom, "Rendered Fonts".

What the mistake costs

The stylesheet half of the setup is all still there and all still shipped:

  • The small stylesheet on every page, 4,114 bytes, holds 13 @font-face rules: 5 Geist subsets, 6 Geist Mono subsets, and two local fallback faces.
  • The fallback faces are the clever part of next/font, and they now guard against nothing:
 @font-face{font-family:Geist Fallback;src:local(Arial);ascent-override:95.94%;descent-override:28.16%;line-gap-override:0.0%;size-adjust:104.76%} @font-face{font-family:Geist Mono Fallback;src:local(Arial);ascent-override:74.67%;descent-override:21.92%;line-gap-override:0.0%;size-adjust:134.59%} 

Those overrides exist to make Arial occupy the same space as Geist so the swap does not shift the layout. There is no swap.

  • Two rel=preload hints per page, so the browser fetches the fonts eagerly whether or not any text needs them. On the 77 prerendered pages they arrive as HTML link tags; on the two per-request pages, / and /pricing, they arrive as a Link response header instead, which you can see with curl -sI https://pub-trivia.app/pricing | grep -i ^link.
  • Total: 52,396 bytes of font per fresh visitor, on any page they land on, discarded.

The second preloaded file is the monospace latin subset. font-mono appears 8 times in the repository, every one of them behind the dashboard or on an error screen, so on the public site the mono face is 23 KB fetched for zero characters, and even behind the login it would render in Menlo.

And Chrome does not complain. I watched the console for nine seconds on a normal page load: one warning, and it is about a deprecated Apple meta tag. The "preloaded but not used" warning that I have seen this site emit when I blocked the stylesheet deliberately does not fire when the stylesheet is present, because the @font-face rules that match the preloads are right there in it. The file is wanted. It is just never asked for a letter.

Three posts I have to correct

This is the part that stings. I have written about these fonts three times and every time I assumed the stack resolved:

  • In the stylesheet selector census I wrote that the two variable rules match "because that is how the fonts are wired in". The rules do match. The wiring is not connected at the other end.
  • In the internationalisation post I parsed the cmap table of our Geist file, found 728 codepoints, and concluded that the Japanese and Turkish prices "render their digits in Geist and their currency symbol in whatever the operating system falls back to". Wrong in one direction only: the digits fall back too.
  • In an earlier post about this site's preload hints I counted the two woff2 files, noted that one of them was a mono face preloaded for five usages, and treated both as the price of a typeface we use.

The lesson I will actually keep: a font that is declared, subset, preloaded, fallback-matched and shipped still has to be selected, and the only honest instrument for that is the renderer. Computed style would have caught this too, in one line, if I had ever thought to read it.

The one place the brand font does render

lib/og/og-image.tsx builds our social cards with Satori, which cannot read woff2 and so loads the vendored .ttf files directly:

readFile(join(process.cwd(), "assets/fonts/Geist-Regular.ttf")), 

and sets fontFamily: "Geist" on the text. That path has nothing to do with the stylesheet, so it works. Open our link preview image next to the page it belongs to and you are looking at two different typefaces: the card is Geist, the site is your own system font.

The fix, and the choice behind it

The repair is one word in app/globals.css:

@theme { --font-sans: var(--font-geist), system-ui, sans-serif; --font-mono: var(--font-geist-mono), monospace; } 

That makes every page render in the typeface we have been paying to download since June.

The other option is to delete the whole thing: drop next/font, drop the 13 @font-face rules, drop 52,396 bytes per visitor, and keep the system stack that has been shipping all along. The site's measured layout shift is already zero, so nobody is going to notice a swap that no longer happens.

What I am not going to do is leave it in the state where we pay for both.

If you want to check any of this yourself, the whole site is public: the sitemap lists all 79 pages, and the free tools under /tools run entirely in the browser, so you can open the console on one of them and read the computed styles while nothing is being uploaded anywhere.