Self-Hosting Fonts in Astro with Fontsource
Why loading from fonts.googleapis.com costs you a render round-trip and leaks visitor IPs — and how @fontsource fixes both with a one-line import and zero config.
Self-Hosting Fonts in Astro with FontsourceThe default way to add a webfont is two lines from Google Fonts: a <link> to fonts.googleapis.com and your text in a typeface you didn’t have to host. It’s convenient. It also costs you two things you probably don’t want to pay.
A render round-trip. Before the browser can lay out text, it has to: connect to fonts.googleapis.com (DNS + TLS), download a CSS file, then connect to fonts.gstatic.com (another DNS + TLS) for the actual font files. That’s two new origins, two handshakes, before a single glyph paints — easily 100–300ms on a cold connection, right on the critical path to first contentful paint.
Your visitors’ IP addresses, handed to Google. Every font request leaks the visitor’s IP to a third party. A German court ruled in 2022 that embedding Google Fonts this way violated GDPR. Whether or not that applies to you, “the page loads a font and also pings Google” is a privacy cost with no upside when the alternative is free.
The alternative is to self-host. @fontsource makes it a one-liner. This is the longer version of one section from my Astro portfolio walkthrough.
The fix: @fontsource#
@fontsource packages open-source fonts (the whole Google Fonts catalog, plus more) as npm modules. You install the font you want, import the weights you need, and your bundler serves them from your own origin as hashed, cacheable assets. No third-party connection, no IP leak, no extra handshake — the font comes down alongside your CSS, from the same server.
Install and import#
Pick your typefaces and install them:
npm i @fontsource/inter @fontsource/noto-serif
Then import the specific subsets and weights from your global stylesheet:
/* src/styles/global.css */
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css";
@import "@fontsource/noto-serif/latin-400.css";
@import "@fontsource/noto-serif/latin-400-italic.css";
@import "@fontsource/noto-serif/latin-700.css";
Each of those CSS files contains an @font-face rule pointing at a .woff2 file inside node_modules. Astro’s bundler (Vite) sees the import, copies the woff2 out to /_astro/ with a content hash in the filename, and rewrites the @font-face src to point there. Result: same-origin font files, fingerprinted, which means you can cache them forever (Cache-Control: immutable — see the _headers setup). First load fetches them; every subsequent page load and repeat visit reads them from disk.
Then just use the family name in your CSS:
:root {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Noto Serif", ui-serif, Georgia, serif;
}
body { font-family: var(--font-sans); }
The family name ("Inter", "Noto Serif") is whatever @fontsource’s @font-face declares — it’s documented on each package’s page, and it’s almost always just the obvious name.
Import only the weights you actually use#
This is the discipline that keeps it fast. Every weight and style is a separate woff2 file — 15–40KB each. Importing @fontsource/inter with no subset pulls in everything: every weight, every style, every language subset. Don’t. Audit your CSS for the weights you genuinely render — usually that’s three or four — and import exactly those:
- Body text at 400, headings at 600 or 700, maybe a 500 for emphasis, maybe one italic. That’s it.
latin-400.css, not400.css— the bare400.cssincludes every language subset (Cyrillic, Greek, Vietnamese, …);latin-400.cssis just the Latin glyphs, a fraction of the size. Unless your content is multilingual, you want thelatin-(orlatin-ext-if you need accented Eastern-European characters) variants.
A site with one sans and one serif, three or four weights total, ships maybe 80–120KB of font — once, then cached. Compare to the Google Fonts path: similar bytes, plus two extra origin handshakes on the critical path, plus the IP leak.
font-display: swap is already handled#
@fontsource’s @font-face rules ship with font-display: swap baked in. That means: text renders immediately in the fallback font (the next entry in your font-family stack), then swaps to the real font when it arrives. No flash of invisible text (FOIT) — at worst a brief flash of unstyled text (FOUT), which is the right tradeoff. Pick fallback fonts in your stack with similar metrics (system-ui, Georgia) and the swap is barely perceptible. You don’t configure any of this — it’s the default.
Variable fonts, if you want one file#
If you use several weights of the same family, a variable font can be lighter than N separate weight files — one file covers a continuous range. @fontsource ships these under a separate scope:
npm i @fontsource-variable/inter
@import "@fontsource-variable/inter/wght.css"; /* covers the full weight axis */
Then font-weight: 350 or font-weight: 625 just works. Worth it when you’d otherwise import four-plus weights of one family; not worth it for a single weight.
Preload the one font that matters (optional)#
For the font used in your above-the-fold text, you can shave a bit more by preloading it — but you need the hashed path, which only exists after a build. Build once, find the file (dist/_astro/inter-latin-400-normal.[hash].woff2), and add to your <head>:
<link
rel="preload"
href="/_astro/inter-latin-400-normal.[hash].woff2"
as="font"
type="font/woff2"
crossorigin
/>
The crossorigin attribute is required even for same-origin fonts — without it the preload is ignored and you’ve achieved nothing. This is a micro-optimization; skip it unless you’re chasing the last few milliseconds of LCP.
Gotchas worth knowing#
latin-400.css, not400.css— the unprefixed file bundles every language subset. Use thelatin-variant unless you actually serve other scripts.- CSS
@importmust come before other rules in the file — standard CSS, but easy to trip on if you reorganize your stylesheet. - The
font-familyname is whatever the package declares — check the package page; it’s usually the plain name, but don’t guess. crossoriginon preload links is mandatory for fonts, same-origin or not. Forget it and the preload silently does nothing.- You don’t need
font-displaydeclarations —@fontsourceincludes them. Adding your own just risks contradicting them.
What you end up with#
Two npm i lines, a handful of CSS imports, and your fonts are same-origin, content-hashed, cacheable-forever assets — no third-party connection, no IP leak, no extra handshake on the path to first paint, font-display: swap handled. It’s strictly better than the Google Fonts <link> on every axis that matters, and it’s less code.
Convenience that costs a render round-trip and your visitors’ IP addresses isn’t convenient. Self-host the font; it’s one import.
This is one of the small decisions that add up to a fast site — the full set is in How to Build a Portfolio Website with Astro.
