---
title: Fonts
description: Self-host web fonts with metric-matched fallbacks and zero runtime requests, declared in code and vendored by the build.
keywords: [google_font!, local_font!, font-display]
---

`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:

```rust
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:

```rust
// 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:

```rust
// 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:

- content-hashed files into `public/fonts/` (served at `/fonts/...`),
- `proa-fonts.lock` beside `Cargo.toml` (the reproducible, checksummed result), and
- a generated module in `OUT_DIR` defining one `pub const FONT_<id>: Font` per declaration.

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:

```bash
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"`).

| Key | Meaning |
|-----|---------|
| `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 = true` | Treat 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 = sans` | `sans`, `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:

```rust
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(())
    }
}
```

```rust
// 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:

```css
@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:

```html
<!-- 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:

```rust
// 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](#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:

```bash
cargo proa font sync
cargo proa font sync --offline   # strict verify, no writes; fails on any drift (the CI gate)
```

| Flag | Meaning |
|------|---------|
| `--offline` | Strict 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`:

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

## Next steps

- [Static assets](/docs/framework/static-assets)
  - Serve static files, embedded assets, and external apps safely.
- [Images](/docs/framework/images)
  - Serve responsive images from static assets, without a build pipeline.
- [Tailwind](/docs/guides/tailwind-intellisense)
  - Style a Proa app, with autocomplete in Rust files.
- [CI / GitHub Actions](/docs/guides/ci)
  - Gate pull requests with proa check, then deploy from the same pipeline.
