Self-Hosting Google Fonts with Fontsource
This guide sits under the Google Fonts vs Self-Hosting decision and is part of the broader Font Loading & Delivery Strategies blueprint. It covers the fastest practical path to self-hosting: the Fontsource packages, which ship Google Fonts' families as npm modules with pre-subset WOFF2 files and ready-made @font-face CSS.
Problem Statement
You have decided to stop loading fonts from fonts.googleapis.com so that you eliminate the third-party connection, the serial CSS-then-font waterfall, and the GDPR-relevant IP transfer. But hand-extracting WOFF2 files, writing @font-face blocks for every weight, and wiring up unicode-range subsets is tedious and error-prone. Fontsource packages all of that into a versioned dependency you import like any other module — while keeping every byte same-origin.
Prerequisites
- A bundler that can resolve npm CSS imports (Vite, webpack, Next.js, Astro, SvelteKit — all work).
- The font's package name. For the variable version of Inter that is
@fontsource-variable/inter; for static weights it is@fontsource/inter. Prefer the variable package when you use multiple weights — one file covers the wholewghtrange, which pairs well with variable font loading techniques. - A place to emit hashed font assets with long cache headers (your normal asset pipeline).
Implementation
Install the package, then import its CSS so the bundler copies the WOFF2 files into your build and registers the @font-face rules. Finally, preload the one weight your above-the-fold text uses.
Install and import Fontsource (variable Inter)
npm install @fontsource-variable/inter
// Import once, near your app entry (e.g. main.js / _app.tsx / layout file).
// This pulls in the @font-face rules AND the subset WOFF2 files,
// emitting them as same-origin, content-hashed assets.
import '@fontsource-variable/inter';
// Then use it anywhere in CSS:
// body { font-family: 'Inter Variable', system-ui, sans-serif; }
Preload the critical weight (so it isn't discovered late)
<!-- Path is the hashed asset your bundler emits for the Latin subset.
crossorigin is REQUIRED even though the file is same-origin,
because font fetches always use CORS mode. -->
<link
rel="preload"
href="/assets/inter-latin-wght-normal.woff2"
as="font"
type="font/woff2"
crossorigin
/>
Walking through the critical lines: the import statement is what does the real work — it resolves to the package's index.css, which contains one @font-face per subset (Latin, Latin-Extended, Cyrillic, Greek, and so on) each carrying its own unicode-range, so the browser only downloads the script a page actually contains. The variable package exposes the family as 'Inter Variable'; you then set font-family: 'Inter Variable' with a system-ui fallback stack behind it. The <link rel="preload"> is decoupled from the import: it tells the browser to fetch the Latin WOFF2 immediately, in parallel with the HTML parse, instead of waiting to discover it through the CSS. Preload exactly one subset/weight — the one your first paint needs — to avoid stealing bandwidth from the LCP resource.
Error and Fallback Variant
If the WOFF2 fails to load (network blip, blocked request, or a CDN miss), you do not want invisible text. Two defenses: a real fallback stack, and an explicit font-display. Fontsource also exposes per-subset and per-weight CSS so you can import only what you need rather than the whole family.
Defensive setup: scoped import, fallback stack, and font-display override
// Import only the Latin subset at weight 400 to cut bytes.
import '@fontsource/inter/latin-400.css';
/* Re-declare with an override so a failed/slow font swaps in cleanly
instead of blocking text. font-display: swap = 0ms block, infinite swap. */
@font-face {
font-family: 'Inter';
src: url('/assets/inter-latin-400.woff2') format('woff2');
font-weight: 400;
font-display: swap; /* use 'optional' to skip the swap on slow nets and kill CLS */
}
:root {
/* The fallback chain renders instantly and keeps text visible
if the WOFF2 never arrives. */
--font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
body { font-family: var(--font-body); }
For the lowest layout shift, swap font-display: swap for optional, and add metric overrides on the fallback to match Inter's metrics — see designing accessible fallback font stacks for the size-adjust and ascent-override values.
Choosing What to Import
Fontsource exposes several import granularities, and the one you pick directly determines how many bytes ship. From coarsest to finest:
import '@fontsource-variable/inter'— the full variable family across every subset Fontsource provides. One file per subset covers the wholewghtrange. Best when you use many weights of one Latin-script family.import '@fontsource/inter'— all static weights and styles. Avoid unless you genuinely render most of them; it is the heaviest option.import '@fontsource/inter/400.css'— a single static weight, all subsets.import '@fontsource/inter/latin-400.css'— a single weight, single subset. The leanest choice, ideal when your UI is Latin-only and uses one or two weights.
A practical rule: start with the most specific import that covers what you render, then widen only if you see fallback text. Pairing a tight import with a preload of the critical weight gives you the smallest possible first-paint payload while keeping everything same-origin.
Each Fontsource subset CSS already carries the correct unicode-range descriptors, so the browser only downloads the script a page actually contains — the same unicode-range subsetting you would otherwise hand-author, generated for you.
Verification
Confirm the migration actually moved the bytes to your origin:
- Build and serve the app, open Chrome DevTools, and select the Network panel.
- Filter by Font and reload with the cache disabled.
- Check the domain. Every font request's domain must be your own origin — there must be zero requests to
fonts.googleapis.comorfonts.gstatic.com. The WOFF2 file should show the same host as your HTML. - Check the priority. The preloaded Latin WOFF2 should appear early in the waterfall at Highest priority, with no "resource was preloaded but not used" warning in the Console.
- Check the runtime. In the Console, run
document.fonts.check('1rem "Inter Variable"')— it returnstrueonce the font is ready, confirming the family registered correctly.
Framework Integration Notes
The import step is bundler-driven, so where you place it depends on your framework, but the principle is identical everywhere: import once, near the entry, so the WOFF2 assets are emitted into the build output as same-origin files.
- Vite / Astro / SvelteKit — import in the root layout or
mainentry; Vite emits hashed font assets and rewrites theurl()automatically. Read the hashed filename frommanifest.jsonto build the preload tag. - Next.js / React — import in
app/layout.tsx(App Router) or_app.tsx(Pages Router). Note thatnext/fontis an alternative built-in self-hosting mechanism; Fontsource is the right choice when you want explicit control over which subset and weight files ship. - webpack — ensure a rule handles
.woff2as an asset/resource so the import resolves the binaries into the output directory.
In all cases the goal is the same as the manual workflow in Google Fonts vs Self-Hosting: zero runtime connection to any Google domain, every font same-origin, and the critical weight preloaded. Fontsource just removes the hand-authoring of @font-face blocks and subset files.
Common Pitfalls
- Importing the full family when you use one weight.
import '@fontsource/inter'pulls every static weight. Use the variable package, or import a singlelatin-400.css, so you ship only the bytes you render. - Preloading the wrong hashed filename. Bundlers content-hash the emitted WOFF2, so a hard-coded preload path goes stale after a rebuild. Generate the preload tag from the build manifest, or preload via your framework's asset helper.
- Omitting
crossoriginon the preload. The font fetch is in CORS mode regardless of origin; withoutcrossoriginthe preloaded file is discarded and downloaded again — double the bytes. - Wrong family name. The variable package registers
'Inter Variable', not'Inter'. Referencing the wrong name silently falls through to the fallback stack and the WOFF2 never paints. - Leaving the old Google
<link>in<head>. A leftover stylesheet link tofonts.googleapis.comre-introduces the third-party hop you were trying to remove.
FAQ
Does Fontsource send any data to Google?
No. Fontsource packages are static WOFF2 and CSS files served from your own origin (or your bundle). After install there is no runtime connection to any Google domain, which is exactly why it satisfies the GDPR concern about IP transfer that motivated leaving hosted Google Fonts.
Should I use the variable package or static weights?
Use the variable package (@fontsource-variable/inter) when you render more than one or two weights, because a single WOFF2 covers the entire wght range and avoids juggling multiple files. Use a static single-weight import when you only render one weight and want the absolute smallest payload.
How do I preload when the filename has a build hash?
Don't hard-code the path. Read the emitted filename from your bundler's manifest (Vite's manifest.json, webpack stats, or your framework's asset helper) and inject the <link rel="preload"> with the resolved hashed URL at build time.