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.

Native server rendering (API)

An API that renders text through Fontconfig/Pango (an SVG → PNG share card via sharp, for example) can declare the same fonts: array in its app.config.ts:

export default {
  type: 'api' as const,
  name: 'cards',
  fonts: [{ family: 'Inter', src: [{ path: '../shared/fonts/Inter.woff2', weight: 400 }] }],
}

voltro build validates the actual font and writes hashed, native-readable TTF/OTF files plus a relocatable Fontconfig configuration under .framework/native-fonts. WOFF/WOFF2 is converted to SFNT; TTF/OTF bytes stay unchanged. There is no second subsetting: the browser-only subsets, display and fallback-metric options do not alter server glyphs. A declared family alias maps to the font's embedded family name; the native font's metadata decides its face, so choose weight/style with your renderer.

voltro dev prepares and activates these fonts before handlers and startups. voltro serve and the built direct Node entry only verify and activate the built artefacts, before startups or HTTP readiness — they need neither the original font paths nor fontkit/fontverter, downloads or build tools. Missing, corrupted or declaration-stale artefacts refuse boot with a named error. Ship the complete .framework directory. A slim image has no host fonts; without this the renderer drew replacement boxes while PNG magic and dimensions looked correct — only the pixels showed it.

The declaration owns the process-wide native font configuration: the framework sets FONTCONFIG_FILE and PANGOCAIRO_BACKEND=fontconfig, and a conflicting FONTCONFIG_FILE, FONTCONFIG_PATH, FONTCONFIG_SYSROOT or Pango backend override is refused, not silently overwritten. Run independent APIs in independent processes. Native libraries cache their fonts, so restart the dev worker after editing font files. The XML references only declared files, never host fonts; preserve the fonts' licence notices, and provide faces for every script you render — declaring a font does not invent missing glyphs.

The native cache uses a writable XDG_CACHE_HOME/fontconfig; when unset, a directory under the OS temp dir. Image layers and font artefacts may stay read-only. The renderer must support Fontconfig — this is not a font loader for other backends; web ogImage's explicit font-byte path stays separate, and an API without fonts: is untouched. For API builds fontkit validates and fontverter converts WOFF/WOFF2; both are optional CLI dependencies, and omitting one when it is needed is a build error, never a silent downgrade.

Production caching

Generated font URLs under /assets/fonts/ contain a twelve-character content hash. The static response policy treats these fingerprinted fonts as immutable assets with a one-year cache lifetime. Unhashed public files keep bounded caching, and HTML keeps its separate freshness policy. Disabling immutable asset caching also applies to generated fonts.

Pages using Islands keep generated font definitions and font preloads in their initial HTML, including server-rendered pages. Global CSS can reference the generated font variables before hydration.