ADR-026: Font Strategy — Self-Hosted Variable Fonts via @fontsource
Status
Section titled “Status”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@fontsourcepackages + 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.
Context
Section titled “Context”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.
Decision Drivers
Section titled “Decision Drivers”- Zero CLS: Fonts must not cause layout shift —
font-display: swapwith 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
Considered Options
Section titled “Considered Options”Option 1: Google Fonts (remote)
Section titled “Option 1: Google Fonts (remote)”<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)”pnpm add @fontsource-variable/interimport '@fontsource-variable/inter';Pros:
- Fonts versioned alongside code in
package.json - WOFF2 variable font files served from
node_modules, copied todist/at build time - No third-party runtime requests
- Automatic updates via
pnpm update - Consistent with npm ecosystem tooling
Cons:
- Adds to
node_modulessize (not shipped to production) - Requires understanding of
@fontsourcepackage naming convention
Decision
Section titled “Decision”Use @fontsource-variable/inter for the default typeface with the following implementation:
Why Inter
Section titled “Why Inter”- 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.
"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).
Loading Strategy
Section titled “Loading Strategy”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.
CSS Variable Integration
Section titled “CSS Variable Integration”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.
Replacing the Default Font
Section titled “Replacing the Default Font”Users who want a different font should:
- Remove
@fontsource-variable/interfrompackage.json - Install their chosen
@fontsourcepackage (e.g.pnpm add @fontsource-variable/geist) - Update the import in
BaseLayout.astro - Update
--font-sansintokens/base.json - Run
pnpm run tokens:build
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- 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
Negative
Section titled “Negative”- Users unfamiliar with
@fontsourcemay 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
Neutral
Section titled “Neutral”- Font subsetting is not applied by default. Users with non-Latin character requirements should subset manually or use a service like
glyphhanger font-display: swapis set by@fontsourceby default, which is correct for this use case
Validation
Section titled “Validation”- 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
References
Section titled “References”- @fontsource documentation
- Inter typeface
- Google Fonts privacy concerns (GDPR) — self-hosting fonts avoids third-party requests
- ADR-000: Starter Template Architecture
- ADR-020: Page Performance Patterns
Enforcement
Section titled “Enforcement”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