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.

Fontsource build pipeline from npm package to same-origin font The npm package is imported, the bundler emits hashed WOFF2 assets to your origin, and a preload tag fetches the critical subset. npm install @fontsource/... import css @font-face rules bundler emits hashed woff2 same origin /assets/*.woff2 link rel=preload pulls the Latin subset early crossorigin required even same-origin
Fontsource turns a font into a versioned dependency whose WOFF2 files are emitted to your own origin and preloaded.

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 whole wght range, 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 whole wght range. 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:

  1. Build and serve the app, open Chrome DevTools, and select the Network panel.
  2. Filter by Font and reload with the cache disabled.
  3. Check the domain. Every font request's domain must be your own origin — there must be zero requests to fonts.googleapis.com or fonts.gstatic.com. The WOFF2 file should show the same host as your HTML.
  4. 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.
  5. Check the runtime. In the Console, run document.fonts.check('1rem "Inter Variable"') — it returns true once 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 main entry; Vite emits hashed font assets and rewrites the url() automatically. Read the hashed filename from manifest.json to build the preload tag.
  • Next.js / React — import in app/layout.tsx (App Router) or _app.tsx (Pages Router). Note that next/font is 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 .woff2 as 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 single latin-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 crossorigin on the preload. The font fetch is in CORS mode regardless of origin; without crossorigin the 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 to fonts.googleapis.com re-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.

Related