Fonts

Self-hosted local fonts — declared once in app.config.ts, delivered as hashed woff2 with @font-face, a size-adjusted fallback face (the CLS guard), a preload link and opt-in subsetting. No font CDN request ever leaves the browser.

Declare local font files once and the framework does the rest: content-hashed self-hosting, @font-face with font-display, a size-adjusted fallback face so the swap moves nothing, a <link rel="preload"> in the shell head, and opt-in unicode-range subsetting.

// app.config.ts (web)
export default {
  type: 'web' as const,
  name: 'MyApp',
  fonts: [{
    family: 'Inter',
    src: [
      { path: 'src/fonts/Inter-Variable.woff2', weight: '100 900' },  // variable range
      // …or discrete faces — Regular+Bold is any real project's minimum:
      // { path: 'src/fonts/Inter-400.woff2', weight: 400 },
      // { path: 'src/fonts/Inter-700.woff2', weight: 700 },
      // { path: 'src/fonts/Inter-Italic.woff2', weight: 400, style: 'italic' },
    ],
    display: 'swap',
    subsets: ['latin'],
    fallback: 'Arial',
  }],
}
import { localFont } from '@voltro/web'

const inter = localFont('Inter')   // { variable: '--font-inter', fontFamily: 'var(--font-inter)' }

export default function Page(): React.ReactElement {
  return <main style={{ fontFamily: inter.fontFamily }}>…</main>
}

The shell defines --font-inter as 'Inter', 'Inter Fallback', Arial, sans-serif — use it from any CSS. Every render mode ships the same head tags (the CSS + preload are baked into the ONE generated shell that dev, static prerender, SSR streaming and voltro start all serve).

Why the fallback face matters (CLS)

Until the web font arrives, text renders in the fallback — and a fallback with different metrics reflows the page when the swap happens. The pipeline reads the font file's real metrics (fontkit) and emits an 'Inter Fallback' face: local('Arial') with size-adjust, ascent-override, descent-override and line-gap-override computed by the capsize formula so the fallback occupies the SAME space. The swap becomes invisible; CLS ≈ 0.

Subsetting

subsets: ['latin'] (and/or 'latin-ext') rewrites each face to just that unicode range via subset-font, declared with a matching unicode-range so the browser only downloads what the page's characters need. Measured in the framework's own e2e: the subset ships at well under half the source size.

GDPR — no font CDN, ever

Nothing here talks to Google Fonts (or any font host) at runtime — the files ship from YOUR origin, content-hashed and immutable. That is the compliance answer German courts made concrete (LG München, remote Google-Fonts embedding): the user's IP never reaches a font CDN because no request leaves your domain. The framework's e2e asserts exactly that — a full page load with zero foreign-host requests.

Getting the files: there is deliberately no Google-Fonts download helper (license terms differ per family — that step stays yours). The manual path: download the family from fonts.google.com (or the foundry), drop the woff2/ttf into src/fonts/, declare it. Done once, committed with the repo.

Tooling (optional, degrades loudly)

fontkit (metrics) and subset-font (subsetting) ship as optional dependencies of @voltro/cli — script-free, nothing to approve. If your installer omits optional dependencies: fonts still self-host with @font-face + preload, but the fallback metrics and subsets are skipped — with ONE named warning each, and voltro doctor reports which half is missing and the fix. Never a silent downgrade.