Docs

Fonts

Self-host web fonts with metric-matched fallbacks and zero runtime requests, declared in code and vendored by the build.

Open Markdown

proa_fonts brings the valuable part of Next.js' next/font to Proa's SSR pipeline: fonts are self-hosted, content-hashed, preloaded, and given metric-compatible fallbacks, all at build time, with no runtime request to a third party and no JavaScript.

Fonts are declared in code, next to the code that uses them. google_font! fetches and vendors a family from Google Fonts; local_font! vendors font files you already have. Both are driven by an ordinary build.rs, and both produce the same self-hosted, metric-matched result, pinned in proa-fonts.lock so rebuilds are offline and reproducible.

Declare fonts in code

Write the font in Rust and let the build vendor it:

use proa_fonts::Font;
use proa_fonts_macros::{google_font, local_font};

static SANS: Font = google_font!("Geist", subsets = [latin], weights = [400, 700], variable = "--font-sans");
static MONO: Font = google_font!("Geist Mono", variable_font = true, generic = mono, variable = "--font-mono");
static BRAND: Font = local_font!("Brand", files = ["fonts/Brand.woff2"], variable = "--font-brand");

The macro performs no I/O; it expands to a reference to a FONT_<id> const that the build step generates, and the static binding gives it whatever name you like. The macro and the build collector parse the call through the same grammar, so the reference and the generated definition always agree. Identical declarations anywhere in the crate share one const and one set of vendored files; distinct declarations get distinct consts.

Wire the build

Two pieces of boilerplate. The origin site template (cargo proa new site --template origin) ships both; the other templates need them added by hand. A build.rs that runs the collector:

// build.rs
fn main() {
    println!("cargo:rerun-if-changed=src");
    println!("cargo:rerun-if-changed=proa-fonts.lock");
    let fonts = proa_fonts_build::collect::CollectOptions::from_build_env()
        .expect("cargo build env");
    if let Err(error) = proa_fonts_build::collect::run(&fonts) {
        panic!("proa fonts: {error}");
    }
}

And one line near your crate root wiring the generated file in:

// somewhere in your crate (once):
mod __proa_fonts_generated {
    include!(concat!(env!("OUT_DIR"), "/proa_fonts.rs"));
}

CollectOptions::from_build_env() reads the crate root and OUT_DIR from the Cargo build environment, vendors into public/fonts (served at /fonts), and keeps the lock beside Cargo.toml. Build the struct by hand to customize those paths; if you do, pass the same values to cargo proa font sync via --fonts-dir / --url-prefix.

Now cargo build does everything: the build script scans your source for google_font! / local_font! calls, fetches Google sources (behind the default-on fetch feature of proa_fonts_build) or reads local files, and vendors the faces. Google serves already-subset WOFF2 per unicode-range, so a subset selection chooses which files to vend, not a glyph-subsetting step.

A build writes three things:

Commit public/fonts/ and proa-fonts.lock. The OUT_DIR module is regenerated by every build and is never committed. The origin template serves public/fonts/ at /fonts through a ServeDir route; other templates serve public/ at /static, so mount /fonts yourself. The filenames are content-hashed, so add a long Cache-Control: max-age with immutable and browsers can cache them forever.

Offline and reproducible

Vendoring is gated by proa-fonts.lock. A declaration whose lock entry and vendored files are intact (SHA-256 and byte size verified) regenerates from the bytes on disk, touching neither the network nor the vendored files. Only new or changed declarations fetch or read; they vendor their faces and update the lock. If a vendored file goes missing or fails its checksum, an online build re-vendors it and updates the lock, so the repair is visible in git; cargo proa font sync --offline fails instead. Declarations you delete get their lock entries dropped and their vendored files deleted (unless another font still references a file). If a source file under src/ fails to parse mid-edit, the collector preserves the existing lock entries and vendored files instead of treating their declarations as removed.

Once the collector has run, the lock always exists: with zero declarations it is a comment-only stub, and removing your last declaration leaves the stub behind, so rerun-if-changed=proa-fonts.lock is sound from the first build. Lock and module writes are skip-if-unchanged, so that directive cannot loop the build.

With the lock and public/fonts/ committed, a fresh checkout builds fully offline and reproducibly: the lock records each vended file's SHA-256, byte size, and URL, plus the measured metrics and the synthesized fallback overrides.

Offline means two different things in a build and in a sync. During a cargo build with CARGO_NET_OFFLINE=true, local fonts still vendor (they need no network); only a Google declaration that would need a fetch fails, with an actionable error. cargo proa font sync --offline is strictly verify-only for all sources: it performs no writes at all and fails with proa-fonts.lock is stale when any declaration, Google or local, is not satisfied by the lock and vendored files, or when the lock content would change (including entries that would be removed), or when any source file under src/ cannot be parsed (unverifiable declarations fail the gate rather than passing vacuously). Plain cargo proa font sync also runs verify-only under CARGO_NET_OFFLINE=true. Gate CI with:

cargo proa font sync --offline   # strict verify, no writes; fails on any drift

Declaration options

Both macros take a family name literal followed by key = value options. List entries can be bare words (latin) or strings ("latin-ext").

KeyMeaning
subsets = [latin, "latin-ext"]Google subsets to vend. Defaults to [latin]. The first subset is the primary one for default preloads.
weights = [400, 700]Fixed weights to vend. Defaults to [400].
styles = [normal, italic]Styles to vend. Defaults to [normal].
variable_font = trueTreat the source as a variable font spanning weights 100..900.
variable = "--font-sans"CSS custom property defined on :root, e.g. for font-family: var(--font-sans).
generic = sanssans, serif, or mono: selects the metric-matched fallback and the generic keyword ending the stack. Defaults to sans.
display = "swap"font-display keyword. Defaults to swap.
fallback = "Helvetica"Override the local(...) fallback face used for metric matching. fallback_local is an accepted alias.
extra_stack = ["system-ui"]Families appended after the fallback, before the generic keyword.
files = ["fonts/Brand.woff2"]local_font! only, required: files vendored relative to the crate root. Weight and style are inferred from filenames (-700-, Italic, ...).
preload = ["latin-400-normal"]Faces to <link rel=preload>. Google entries match <subset>-<weight>-<style> tails; local entries match filename stems. Default: the primary subset's upright 400 (or upright variable) face for Google, the upright faces for local files.
license = "OFL-1.1"License recorded in proa-fonts.lock for auditability.

Use it in your layout

A generated Font renders everything it needs into the document <head> with one call on the static you declared. Assign the font on <html> by class name (direct font-family) or by referencing its CSS variable:

use proa_core::{WebContext, WebRenderSync, WriteError};
use proa_fonts::Font;
use proa_fonts_macros::google_font;
use proa_macros::html_sync;

static SANS: Font = google_font!("Geist", subsets = [latin], weights = [400, 700], variable = "--font-sans");
static MONO: Font = google_font!("Geist Mono", variable_font = true, generic = mono, variable = "--font-mono");

impl WebRenderSync for DocumentHead {
    fn render(self, cx: &mut WebContext) -> Result<(), WriteError> {
        // Preload hints + <style> with @font-face, the metric-matched
        // fallback, and the utility rules.
        SANS.head(cx)?;
        MONO.head(cx)?;
        Ok(())
    }
}
// Apply it on the root element, either as a class:
html_sync! { <html class={SANS.class_name()}> /* ... */ </html> }

// ...or reference the variable the font defines on :root:
//   body { font-family: var(--font-sans); }

head() is allocation-free, writes into the buffer owned by the context, and is generic over the output buffer, so it composes inside any normal WebRenderSync head rendered with the default NoLoader context. It emits the stylesheet via StaticRaw (developer-controlled generated content), never re-escaping it.

Metric-matched fallbacks

The reason self-hosting alone is not enough is layout shift. While a web font loads, the browser paints a fallback (Arial, Times New Roman, Courier New). If that fallback has different metrics, text reflows when the real font swaps in.

The build pipeline parses the web font's vertical metrics and average character width, then synthesizes a fallback @font-face with ascent-override, descent-override, line-gap-override, and a computed size-adjust so the fallback occupies the same space as the real font. The swap becomes invisible. This is the same technique the fontaine and @capsizecss/metrics projects use, done here at vendor time with no extra dependency.

The generated stylesheet looks like this:

@font-face{font-family:'Geist';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/geist-latin-400-normal.9f2c1a3b.woff2) format('woff2');unicode-range:U+0000-00FF}
@font-face{font-family:'Geist Fallback';src:local('Arial');ascent-override:95.02%;descent-override:24.02%;line-gap-override:0.00%;size-adjust:101.24%}
.__proa_font_geist_9f2c1a3b{font-family:'Geist','Geist Fallback',sans-serif}
:root{--font-sans:'Geist','Geist Fallback',sans-serif}

Migrating off runtime Google Fonts

A page that injects a Google stylesheet at runtime pays for a render-blocking third-party request and a flash of unstyled text:

<!-- before: runtime request to fonts.googleapis.com, plus JS -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;700&display=swap" rel="stylesheet">

Replace both links with a declaration and one head call:

// after: self-hosted, preloaded, metric-matched, no third-party request
static SANS: Font = google_font!("Geist", subsets = [latin], weights = [400, 500, 700], variable = "--font-sans");

// where you build the head:
SANS.head(cx)?;

A hand-written @font-face block (naming files you self-host manually) migrates the same way: declare the files with local_font!, delete the hand-written CSS and <link>s, and call head(cx) where you build the head.

From the removed CLI workflow

Earlier versions vendored fonts with cargo proa font add into proa-fonts.toml and src/fonts/<key>.rs. That workflow is gone; declarations live at the call site now. To migrate:

  1. Rewrite each proa-fonts.toml entry as a google_font! / local_font! static and replace crate::fonts::GEIST-style imports with your own bindings.
  2. Add the build.rs call and the include! line from Wire the build.
  3. Delete proa-fonts.toml and src/fonts/; keep public/fonts/ and proa-fonts.lock committed.

Lock entries written by the old CLI lack the declaration fingerprint the collector now records, so the first cargo build or cargo proa font sync re-vendors them once (Google families need the network for that one run). After that, builds are offline again.

Command reference

cargo proa font runs the same collector the build script uses, outside a build. It never emits the OUT_DIR module; the next cargo build owns that.

sync

Vendor and lock every font declared in the project's source, right now. Under CARGO_NET_OFFLINE=true, sync runs verify-only instead of vendoring:

cargo proa font sync
cargo proa font sync --offline   # strict verify, no writes; fails on any drift (the CI gate)
FlagMeaning
--offlineStrict verify, no writes: exit non-zero with proa-fonts.lock is stale when any declaration (Google or local) is not satisfied by the lock and vendored files, or when the lock content would change, including entries that would be removed.
--fonts-dir <DIR>Vendored-files directory. Defaults to public/fonts; must match the build script's CollectOptions when that is customized.
--url-prefix <PREFIX>URL prefix referenced by the generated CSS. Defaults to /fonts.
--root <DIR>Project root. Defaults to the nearest root.

list

Show the fonts recorded in proa-fonts.lock:

cargo proa font list
cargo proa font list --json   # stable JSON, includes measured metrics and fallback overrides

Next steps

Search

Type at least 2 characters