Nakodo uses three typefaces: Besley for display, Archivo for everything else, and IBM Plex Mono for addresses, handles and catalogue numbers. They are declared once, in the root layout, with three calls:
import { Archivo, Besley, IBM_Plex_Mono } from "next/font/google";
// Fonts are downloaded at build time and served from our own domain, so the
// browser never contacts Google.
const display = Besley({
subsets: ["latin", "latin-ext"],
style: ["normal", "italic"],
variable: "--font-besley",
});
const ui = Archivo({
subsets: ["latin", "latin-ext"],
axes: ["wdth"], // condensed labels
variable: "--font-archivo",
});
const code = IBM_Plex_Mono({
subsets: ["latin", "latin-ext"],
weight: ["400", "500"],
variable: "--font-plex-mono",
});
The first claim in that comment is true and checkable:
$ curl -s https://nakodo.app/ | grep -c 'gstatic\|fonts.googleapis'
0
The second thing I believed, that subsets: ["latin", "latin-ext"] means two files per family, is not what the option does.
What the deployment actually serves
One CSS chunk carries every @font-face. On the live site:
$ curl -s https://nakodo.app/ | grep -o 'href="/_next/static/[^"]*\.css"'
href="/_next/static/immutable/chunks/3lx8-zlp93hp8.css"
href="/_next/static/immutable/chunks/10cobkkku2ww9.css"
$ curl -s https://nakodo.app/_next/static/immutable/chunks/3lx8-zlp93hp8.css \
| grep -o '@font-face' | wc -l
20
Twenty rules, 17 of them pointing at a woff2 file. Pulling each one's family, style, weight, unicode range and transferred size out of that CSS gives the whole inventory:
| Family | Style | Weight | Subset | Preloaded | Bytes |
|---|---|---|---|---|---|
| Besley | italic | 400 900 | latin-ext | yes | 19,480 |
| Besley | italic | 400 900 | latin | yes | 39,476 |
| Besley | normal | 400 900 | latin-ext | yes | 18,440 |
| Besley | normal | 400 900 | latin | yes | 36,532 |
| Archivo | normal | 100 900 | vietnamese | no | 34,364 |
| Archivo | normal | 100 900 | latin-ext | yes | 85,856 |
| Archivo | normal | 100 900 | latin | yes | 90,096 |
| IBM Plex Mono | normal | 400 | cyrillic-ext | no | 4,348 |
| IBM Plex Mono | normal | 400 | cyrillic | no | 5,476 |
| IBM Plex Mono | normal | 400 | vietnamese | no | 4,000 |
| IBM Plex Mono | normal | 400 | latin-ext | yes | 8,860 |
| IBM Plex Mono | normal | 400 | latin | yes | 10,052 |
| IBM Plex Mono | normal | 500 | cyrillic-ext | no | 4,336 |
| IBM Plex Mono | normal | 500 | cyrillic | no | 5,500 |
| IBM Plex Mono | normal | 500 | vietnamese | no | 4,036 |
| IBM Plex Mono | normal | 500 | latin-ext | yes | 8,848 |
| IBM Plex Mono | normal | 500 | latin | yes | 10,060 |
389,760 bytes of font files on the CDN, of which the ten marked preloaded are 327,700. Seven files, 62,060 bytes, are Cyrillic and Vietnamese cuts of two families, for subsets that appear in none of the three calls.
The bundled Next docs say exactly what the option is, once you read it as written:
The font
subsetsdefined by an array of string values with the names of each subset you would like to be preloaded. Fonts specified viasubsetswill have a link preload tag injected into the head when thepreloadoption is true, which is the default.
So subsets is a preload list. Every subset the upstream font has gets an @font-face rule with its unicode-range, and the ones you named are the ones that get preloaded; the rest are declared and fetched only if a character in their range is painted. Nothing is wasted at request time, which is why the mistake is invisible: the Cyrillic files sit on the CDN and are never requested, because our pages have no Cyrillic in them. Had I kept believing subsets filtered the build, I would have gone looking for a bug the day a creator's name arrived with a Cyrillic character in it and rendered correctly.
The three rules without a file are the fallbacks:
@font-face{font-family:Besley Fallback;src:local(Times New Roman);ascent-override:100.86%;descent-override:34.29%;line-gap-override:0.0%;size-adjust:123.94%}
@font-face{font-family:Archivo Fallback;src:local(Arial);ascent-override:88.96%;descent-override:21.28%;line-gap-override:0.0%;size-adjust:98.7%}
@font-face{font-family:IBM Plex Mono Fallback;src:local(Arial);ascent-override:76.16%;descent-override:20.43%;line-gap-override:0.0%;size-adjust:134.59%}
Those numbers are metric matching, computed at build time from the real font's own metrics. They are the reason text does not reflow when the web font lands: the local fallback is scaled and its ascent, descent and line gap are overridden so it occupies the same space. 123.94% against Times is also a fair description of Besley, a Clarendon revival with a much larger x-height.
The third rule is the one to notice. IBM Plex Mono's fallback is Arial, not a monospace face, and size-adjust matches a vertical metric rather than an advance width. So in the moment before the mono file arrives, our tables of addresses and handles are set in a proportional font at 134.59%: right height, wrong grid. That is Next's generated fallback rather than a choice of mine. The two options that touch it are fallback: ["ui-monospace", "monospace"], which supplies the font-family list, and adjustFontFallback: false, which the docs describe as switching off the automatic layout-shift fallback altogether. Both are trades: lose the metric match, or keep a proportional stand-in for a monospace column.
One width axis instead of a second family
Archivo is the biggest download here, 176 KB preloaded for two subsets, and that is deliberate:
axes: ["wdth"], // condensed labels
By default next/font/google includes only the weight axis of a variable font, to keep the file small. Asking for wdth gives one file that covers font-stretch: 62% to 125%, which the CSS then uses for every column header, kicker and tag on the site:
/* Condensed caps for column headers, kickers and tags. */
@utility label-caps {
font-family: var(--font-archivo), ui-sans-serif, system-ui, sans-serif;
font-stretch: 75%;
font-weight: 600;
font-size: 11px;
line-height: 16px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
A condensed label family would have been a fourth font and a fourth set of files. The axis costs about 30 KB inside a file that was being downloaded anyway. That is the best trade in this whole setup.
The variables, and the one line that makes them work
The three calls only produce CSS variables. The mapping to Tailwind's own font tokens happens once:
@theme inline {
--font-sans: var(--font-archivo), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--font-besley), ui-serif, Georgia, serif;
--font-display: var(--font-besley), ui-serif, Georgia, serif;
--font-mono: var(--font-plex-mono), ui-monospace, monospace;
}
The naming is deliberate and was nearly not. The obvious thing is to name the next/font variable --font-sans and be done, which produces --font-sans: var(--font-sans), a self-reference that only resolves because @theme inline substitutes at use site rather than storing the value. Naming the font variables after the fonts means the theme layer is the only place that decides which font is the sans.
Two base rules then do all the typographic work the components do not:
html {
@apply font-sans;
font-feature-settings: "ss01" 0;
}
table,
[data-numeric] {
font-variant-numeric: tabular-nums;
}
ss01 off globally is a taste decision about Archivo's alternates, and it is one line rather than a utility nobody would remember to add. [data-numeric] is the attribute version of the same thought: any element that holds figures gets tabular numerals, so a column of counts does not wobble as it updates.
59 KB of italic that nothing paints
Here is the finding I did not want. style: ["normal", "italic"] on Besley preloads four files, and the two italic ones are 58,956 bytes of the 327,700 preloaded on every page. Italic Besley exists for margin annotations and for the display line on the 404 page. Grep the live pages for it:
$ for u in / /pricing /how-it-works /about; do
echo "$u $(curl -s https://nakodo.app$u | grep -o italic | wc -l)"
done
/ 2
/pricing 2
/how-it-works 2
/about 2
Two hits on every page, and both of them are the same string inside the React payload: the 404 page's font-display text-xl text-muted-foreground italic with its "Pagina absens". Not rendered, just serialised. No public page paints a single italic glyph, and all of them preload 59 KB of italic to be ready for it.
The fix is a second call rather than a second style:
const display = Besley({ subsets: ["latin", "latin-ext"], variable: "--font-besley" });
const displayItalic = Besley({
subsets: ["latin", "latin-ext"],
style: "italic",
variable: "--font-besley-italic",
preload: false,
});
preload is per call, not per style, so the only way to stop preloading one style of one family is to declare it separately. That costs a second variable and a utility that switches to it, which is more code than the 59 KB is worth on a marketing page and less than it is worth on the app, where the annotation style actually appears. I have not decided yet, and I would rather publish the measurement than a conclusion I have not tested.
Everything here is measurable from outside: the CSS chunk is public, the font files are public, and the pages that use them are the landing page, pricing and the guides.
Top comments (0)