Inline fonts as a Vite plugin: no FOUT, no font swap, no separate request
A 90-line Vite plugin that base64-encodes woff2 files at build time and emits a single <style> block of @font-face rules into <head>. Pixel display fonts ship inline with the HTML and are available at first paint with zero network round trips.
The problem
A custom webfont costs you a network round trip and an uncomfortable choice between FOUT and FOIT. Set font-display: swap and you flash the fallback first, then re-render with the real font when it arrives. Set font-display: block and the page sits there with invisible text until the font lands. Either way, the first paint is wrong.
For a body font like Inter or Roboto the trade-off is accepted: the file is too big to inline, you accept FOUT, you move on. For a small pixel display font, though, that rationale falls apart. The font is 30 kB. The HTML is 60 kB with critical CSS already inlined. Adding 30 kB of base64 font bytes to the HTML costs less than the round trip you'd otherwise pay, and the entire FOUT/FOIT category disappears.
So I wrote a Vite plugin that does exactly that.
The fix in 30 seconds
Drop the plugin into your Vite config, point it at the woff2 files in public/font/, list which ones to inline and what subset they cover. The plugin reads the bytes at build time, base64-encodes them, and injects a single <style id="inline-fonts"> block of @font-face rules into the <head> of every HTML response.
// vite.config.js
import inlineFonts from './build/vite-plugin-inline-fonts.js'
export default {
plugins: [
inlineFonts({
publicDir: 'public',
faces: [
{
file: 'font/jersey-10.woff2',
family: 'Jersey 10',
weight: 'normal',
style: 'normal',
display: 'block',
unicodeRange: 'U+0000-00FF, U+2000-206F'
},
{
file: 'font/jersey-10-ext.woff2',
family: 'Jersey 10',
weight: 'normal',
style: 'normal',
display: 'block',
unicodeRange: 'U+0100-024F'
}
]
})
]
} That's the whole integration. The browser parses <head>, hits the inline @font-face block, decodes the base64 payload, and the font is in memory before the first paint kicks off. No FOUT, no FOIT, no separate request.
How it works
The plugin has three jobs: read the woff2 files, build the CSS, and inject it into the HTML.
Reading is one fs.readFileSync per face, then buf.toString('base64'). Cached on first call so the dev server doesn't re-read on every request.
The CSS is a string of @font-face blocks, one per file. The trick is the src descriptor: a data URL with the woff2 mime type and the base64 payload inline.
@font-face {
font-family: 'Jersey 10';
src: url(data:font/woff2;base64,d09GMgABAAAAA...) format('woff2');
font-weight: normal;
font-style: normal;
font-display: block;
unicode-range: U+0000-00FF, U+2000-206F;
} Injection uses Vite's transformIndexHtml hook with order: 'pre' so the <style> block lands as the first child of <head>, before the critical-CSS block, the favicon, the meta tags. The font bytes need to be available early enough that the browser doesn't kick off a separate fetch before parsing them.
The hook runs in dev too, which means hot-reloading a SFC that uses the font shows the right glyphs immediately instead of falling back to system sans for a frame.
Gotchas
Size discipline matters. Base64 is roughly 33% larger than the binary. A 30 kB woff2 becomes 40 kB of inlined string. If you inline a 200 kB Inter, you've added 265 kB to every HTML response. The math only works for small display fonts.
Subset by unicode-range. Jersey 10 ships as a basic-Latin file plus a Latin-Extended file. The browser only decodes the subset it needs for the glyphs on the page, but it parses both @font-face rules at startup. Splitting the font into multiple subset files (and inlining each with its unicode-range declared) lets you ship the bare minimum for the user's likely content.
font-display: block is correct here. The "block" value normally means "hide text until the font arrives", which is bad. With inlined fonts the font is available at parse time, so there's nothing to wait for. The font swap states never trigger.
HTML caching. Inlining the font into HTML means HTML cache invalidation now requires invalidating the font version too. If you put the font in long-term cache headers via a separate request, you got cheap font reuse across pages. Inlining gives that up. For a portfolio with tens of HTML routes, the saved network round trip is worth more than the cache reuse. For a 10,000-page site, run the numbers first.
SSR responses. If you're streaming HTML from a Node SSR server, the inlined font payload is part of the response body. That's fine, but be aware that compression (gzip / brotli) will help less than you'd expect because base64 doesn't compress as well as raw woff2 binary. The inlined font ends up roughly 1.5x the size of the original file after gzip. Still worth it for the latency win.
Where it fits
Pixel display fonts (Jersey 10, Pixelify Sans, Press Start 2P, MorePerfect DOS VGA). Small icon fonts. Anywhere the font is a defining part of the brand and the file is under ~50 kB.
Don't use it for body fonts. A typical sans-serif (Inter, Roboto, Geist) is multiple hundreds of kB across all weights. Self-host them, put them in long-term cache, accept the single round trip on first visit, eat the FOUT.
Wrap-up
The plugin lives at build/vite-plugin-inline-fonts.js in the repo for this site. Ninety lines. No dependencies. The companion piece is the critical-CSS plugin, which runs in the same transformIndexHtml phase and inlines the above-the-fold styles next to the font block. Together they cover almost everything you can do at build time to make first paint immediate. The remaining work is on the architecture side, which is what the CWV write-up is about.