Skip to content

ADR-026: Font Strategy — Self-Hosted Variable Fonts via @fontsource

Superseded by ADR-053

The self-hosting goal (no third-party requests, latin subset, swap, variable weights) is retained — ADR-053 keeps all of it, but delivers it via the Astro 6 Fonts API (local provider, vendored woff2) instead of the @fontsource packages + manual inline @font-face. The Fonts API adds metric-adjusted fallback faces (size-adjust/ascent-override) to cut CLS, which this approach lacked. See ADR-053 for the current strategy.

Typography is one of the most common first customisations users make to this starter. The font loading strategy has direct implications for:

  • Performance: Font files are a common source of render-blocking and layout shift (CLS)
  • Privacy: Google Fonts makes third-party requests that may violate GDPR/privacy regulations
  • Reliability: External font CDNs introduce a network dependency
  • Core Web Vitals: Poor font loading causes CLS and LCP regressions

The project ships with Inter as the default typeface. This ADR documents why and how.

  • Zero CLS: Fonts must not cause layout shift — font-display: swap with preloading is required
  • Privacy-first: No third-party requests at runtime by default
  • Performance budget: Font files must not meaningfully impact LCP
  • Variable fonts preferred: Single file covers all weights, reducing HTTP requests
  • Self-contained: The starter must work offline and without external dependencies
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet" />

Pros:

  • Zero setup, widely known
  • Automatic subsetting by Google

Cons:

  • Third-party DNS lookup + connection on every page load (~100-300ms on cold connections)
  • GDPR/privacy concerns — IP addresses sent to Google
  • Requires internet connection during development
  • No control over file format or caching headers
  • Google Fonts deprecated direct WOFF2 variable font URLs in 2023

Option 2: Manual self-hosting (download and commit font files)

Section titled “Option 2: Manual self-hosting (download and commit font files)”

Pros:

  • Full control over files
  • No npm dependency

Cons:

  • Manual update process when font versions change
  • Files committed to git (repo bloat)
  • No automatic subsetting tooling

Option 3: @fontsource npm packages (chosen)

Section titled “Option 3: @fontsource npm packages (chosen)”
Terminal window
pnpm add @fontsource-variable/inter
import '@fontsource-variable/inter';

Pros:

  • Fonts versioned alongside code in package.json
  • WOFF2 variable font files served from node_modules, copied to dist/ at build time
  • No third-party runtime requests
  • Automatic updates via pnpm update
  • Consistent with npm ecosystem tooling

Cons:

  • Adds to node_modules size (not shipped to production)
  • Requires understanding of @fontsource package naming convention

Use @fontsource-variable/inter for the default typeface with the following implementation:

  • Designed for screen readability at all sizes
  • Excellent Latin character coverage
  • Variable font available (single file, all weights)
  • Widely used in developer tooling and SaaS products — familiar to the target audience
  • Permissive SIL Open Font License

Amendment (ADR-047): dual-face setup — Geist display + Inter text

Section titled “Amendment (ADR-047): dual-face setup — Geist display + Inter text”

The v2 cold-minimal design language pairs a bold geometric display face for headlines with Inter for body text, and exposes typography as a swappable fontFamily token group (display / text). We self-host Geist (via @fontsource-variable/geist) as the display face and keep Inter for body copy. Both are variable WOFF2 faces loaded with the same preload +font-display: swap pattern as Inter.

tokens/base.json
"fontFamily": {
"display": { "value": "\"Geist\", \"Space Grotesk\", ui-sans-serif, system-ui, sans-serif" },
"text": { "value": "\"Inter\", ui-sans-serif, system-ui, sans-serif" }
}

Constraints carried over from this ADR: two faces maximum, both variable, subset to the used range, font-display: swap, and total font CSS kept under ~15KB gzipped. Headings use var(--font-display); body uses var(--font-text) (set as --default-font-family).

Fonts are preloaded in BaseLayout.astro / Head.astro to eliminate render-blocking:

---
import '@fontsource-variable/inter/wght.css';
---

The variable font (wght axis) covers weights 100–900 in a single ~95KB WOFF2 file. This is preferable to loading multiple static weight files.

The font family is exposed as a design token CSS variable:

:root {
--font-sans: 'Inter Variable', system-ui, sans-serif;
}

This allows users to swap the font by changing a single token value without hunting through component files.

Users who want a different font should:

  1. Remove @fontsource-variable/inter from package.json
  2. Install their chosen @fontsource package (e.g. pnpm add @fontsource-variable/geist)
  3. Update the import in BaseLayout.astro
  4. Update --font-sans in tokens/base.json
  5. Run pnpm run tokens:build
  • Zero third-party font requests — privacy compliant by default
  • Fonts versioned in package.json — reproducible builds
  • Variable font = single HTTP request for all weights
  • Works offline and in CI without network access
  • Users unfamiliar with @fontsource may not know how to swap fonts
  • Variable font file (~95KB uncompressed) is larger than a single static weight (~20KB) — acceptable trade-off for covering all weights
  • Font subsetting is not applied by default. Users with non-Latin character requirements should subset manually or use a service like glyphhanger
  • font-display: swap is set by @fontsource by default, which is correct for this use case
  • CLS: Cumulative Layout Shift must remain < 0.05 with fonts loaded
  • No external requests: Verified via network tab — zero requests to fonts.googleapis.com or fonts.gstatic.com
  • Lighthouse: Font loading must not appear in “Eliminate render-blocking resources” audit

Not enforced — this record’s status is Superseded; only Accepted ADRs are binding (see the status table in the ADR README and ADR-039).


Date: 2026-02-18
Participants: Template maintainers
Outcome: Accepted