# payload-fonts

URL: /docs/plugins/payload-fonts

Upload custom fonts to Payload and self-host them, subset to web-ready WOFF2s and served through next/font.

`@pro-laico/payload-fonts` combines the performance of Next.js local fonts with the ease of choosing
your site's fonts from a CMS and automatically optimizes every uploaded file, shrinking its size
and saving you time.

```bash
pnpm add @pro-laico/payload-fonts
```

## What's included

Custom, self-hosted fonts with none of the usual setup:

- **Automatic optimization:** every uploaded font is subsetted to a lean, web-ready WOFF2 on save — smaller files, no manual tooling or command-line fontkit.
- **Fonts chosen in the CMS:** pick which typeface is your sans / serif / mono / display (or your own families) from the admin, and swap them anytime with no code change.
- **Self-hosted, no third-party CDN:** fonts are served from your own app through [`next/font/local`](https://nextjs.org/docs/app/api-reference/components/font) — fast, private, preloaded, and size-adjusted against layout shift.
- **Zero-config in dev, optimized in prod:** a runtime component serves the live selection while you work; a build step bakes `next/font` for production. The two paths never both fire.
- **Declarative seeding:** seed typefaces like any other doc through [`@pro-laico/payload-seed`](/docs/plugins/payload-seed) — upload the raw files to `fontOriginal` and reference them from the typeface.

![Fonts rendered from CMS in the /dev/fonts route](/screenshots/fonts.png)

## Quickstart

**Add the plugin**

```ts
import { buildConfig } from 'payload'
import { fontsPlugin } from '@pro-laico/payload-fonts'

export default buildConfig({
  plugins: [fontsPlugin()],
})
```

**Externalize the subsetter (Next.js)**

Set `subset-font`, `harfbuzzjs`, and `fontkit` as `serverExternalPackages` so Next doesn't bundle
them (bundling breaks the wasm and optimization silently skips):

```js
// next.config.mjs
import { withPayload } from '@payloadcms/next/withPayload'

const nextConfig = { serverExternalPackages: ['subset-font', 'harfbuzzjs', 'fontkit'] }

export default withPayload(nextConfig)
```

**Wire it into your layout**

Two additions to your root layout, each a no-op in the other environment:

- `extractFonts(...)` on `<html>`: the production `next/font` classes.
- `<DevFonts />` in `<head>`: the dev-only runtime path.

```tsx
import config from '@payload-config'
import definitionFonts from '@/app/definition'
import { extractFonts } from '@pro-laico/payload-fonts'
import { DevFonts } from '@pro-laico/payload-fonts/DevFonts'

<html className={extractFonts(definitionFonts)}>
  <head><DevFonts config={config} definition={definitionFonts} /></head>
</html>
```

**Use the fonts**

The families are CSS variables. With Tailwind v4, map them once and use `font-sans` / `font-mono` as usual:

```css
@theme {
  --font-sans: var(--font-setSans);
  --font-mono: var(--font-setMono);
}
/* plain CSS: body { font-family: var(--font-setSans), sans-serif } */
```

**Generate fonts for production builds**

Production self-hosts via `next/font/local`. The CLI writes the fonts + the `src/app/definition.ts`
your layout imports; run it on `prebuild` (real fonts for the build) and `predev` (an empty stub so
the import resolves before your dev server is up; `<DevFonts>` serves at runtime). Both outputs are
generated, so **gitignore** `src/app/definition.ts` and `public/fonts/`; there's no file to hand-create
([reference](#serving)):

```json
// package.json
{
  "scripts": {
    "build": "next build",
    "prebuild": "payload-fonts-download",
    "predev": "payload-fonts-download",
    "generate:types": "payload generate:types",
    "generate:importmap": "payload generate:importmap"
  }
}
```

`payload-fonts-download` authenticates against your running Payload instance, so set two env vars:

```bash
# .env
FONT_DOWNLOAD_URL=http://localhost:3000   # the running Payload URL the CLI fetches from
PAYLOAD_SECRET=…                          # already in your Payload project, reused as the bearer token
```

## Plugin options

`fontsPlugin(options?)` is the single entry point; one call registers the three collections, the
`fontSet` global, and the export endpoint (all detailed below). It's zero-config.

Pass options to customize. **Reference** is the interactive view; **TypeScript** is the same shape
in code, every option at its default.

**Reference**

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | When false, the plugin is a no-op and registers nothing. |
| `charset` | `'latin' \| 'latin-ext' \| string` | `'latin'` | Characters the subsetter keeps in the served WOFF2 files: a preset (latin is ASCII + Latin-1 + common punctuation, latin-ext widens it), or an explicit string of characters to retain. |
| `families` | `FontFamilyConfig[]` | `sans / serif / mono / display` | Font slots. The COMPLETE list: it replaces the four defaults wholesale (spread DEFAULT_FONT_FAMILIES to keep them). Each entry: |
| `families.key` | `string` |  | Family id. Becomes a family option, a fontSet slot, and (capitalised) font<Key> / --font-set<Key>. _(required)_ |
| `families.label` | `string` | `capitalised key` | Admin label for the family option and fontSet slot. |
| `families.fallback` | `string` | `generic sans stack` | CSS fallback appended after the served family in the family variable. |
| `includeFontSet` | `boolean` | `true` | Register the fontSet global, the active per-family selection the export endpoint and your frontend read. On by default; set false only if you drive that selection some other way (without it the export endpoint has nothing to resolve). |
| `fontOverrides` | `Partial<CollectionConfig>` |  | Merged onto the visible font typeface collection, e.g. tighter access. |
| `fontOriginalOverrides` | `Partial<CollectionConfig>` |  | Merged onto the hidden fontOriginal upload collection, e.g. an upload.staticDir or a client-uploads (direct-to-Blob) adapter. |
| `fontOptimizedOverrides` | `Partial<CollectionConfig>` |  | Merged onto the hidden fontOptimized upload collection (the served WOFF2s), e.g. an upload.staticDir. |
| `fontSetOverrides` | `Partial<GlobalConfig>` |  | Merged onto the fontSet global. |

**TypeScript**

```ts
import { fontsPlugin } from '@pro-laico/payload-fonts'

// Every option at its default. This is the zero-config behaviour, written out.
fontsPlugin({
  enabled: true,
  charset: 'latin',
  includeFontSet: true,
  // families: [{ key: 'sans' }, { key: 'serif' }, { key: 'mono' }, { key: 'display' }],
  //   ^ the default, written out. Pass your own COMPLETE list to replace it
  //     (spread DEFAULT_FONT_FAMILIES to keep these and add more).
  // fontOverrides: { /* … */ },          // optional, no default; merged onto the font collection
  // fontOriginalOverrides: { /* … */ },  // optional, no default; merged onto fontOriginal
  // fontOptimizedOverrides: { /* … */ }, // optional, no default; merged onto fontOptimized
  // fontSetOverrides: { /* … */ },       // optional, no default; merged onto the fontSet global
})
```

Overrides merge non-destructively: `fields` append, `access` / `admin` / `upload` shallow-merge,
and `hooks` merge per phase, so `fontOriginalOverrides: { upload: { staticDir } }` keeps the built-in
font-mime whitelist.

## Collections

Three collections under an **Assets** admin group: one typeface collection you edit, plus two hidden
upload collections it derives. The pipeline is `font` → `fontOriginal` (archive) → `fontOptimized`
(served), each font file flowing one slot at a time.

### `font`

One document per **typeface** (e.g. "Inter"), and the only one you touch. It's *not* an upload
collection; it holds Payload `upload` slots pointing at `fontOriginal`, carried one of two
mutually-exclusive ways: a single `variable` file or a `weights` array. On save every referenced
original is subsetted to a `fontOptimized` WOFF2 (variable fonts keep their `wght` axis range; static
files take the row's weight/style); delete cascades to both. Read and writes are logged-in-admin only.

**Fields**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `text` |  | The typeface name. Required; the admin title and list-search field. |
| `family` | `radio` |  | The generic family it fills: sans / serif / mono / display by default (customisable via the plugin's families option). Required. |
| `variable` | `group` |  | A single variable file per upright / italic (covers many weights). Hidden once weights are added; use this OR weights, not both. |
| `weights` | `array` |  | One file per weight (100–900) + style (normal/italic); each weight+style pair must be unique. Hidden once a variable file is added. |

**Hooks**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `requireFontFiles` | `beforeValidate` |  | Requires at least one file and rejects mixing a variable font with specific weights. |
| `rejectSharedOriginals` | `beforeValidate` |  | Enforces one fontOriginal per typeface, rejecting a save that references an original already used by another typeface (keeps asset cleanup safe). |
| `optimizeFromOriginals` | `afterChange` |  | Subsets each referenced original to a served fontOptimized WOFF2, and cleans up any original a swapped/removed slot de-referenced. |
| `cleanupFontAssets` | `beforeDelete` |  | Cascades the delete to the served fontOptimized files and the archived fontOriginal bytes. |

### `fontOriginal`

The hidden archive of truth: the raw, untouched files editors drop into the `font` slots. Kept so
the (lossy, subsetted) output can be re-derived with a different charset later. An upload collection
whitelisting the four web-font formats; you never see it in nav, only through the `font` fields.

**Fields**: none beyond the upload itself (the stored original bytes). Accepts `woff` / `woff2` /
`ttf` / `otf` via an `upload.mimeTypes` whitelist (OTF/TTF arrive under several sfnt mime strings).

**Hooks**: none; uploading here is a plain store, which is exactly what lets it run as a
client-upload (direct-to-Blob) collection in production. Each original belongs to exactly one
typeface.

### `fontOptimized`

The hidden, derived collection of WOFF2 bytes the site actually serves: one upload doc per
weight/style (or variable file), written by `font`'s save hook, never hand-uploaded. Read is
**public** so the build-time export can fetch them on cloud storage; writes stay gated.

**Fields**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `file` | `upload` |  | The subsetted WOFF2 bytes (the upload). |
| `font` | `relationship` |  | The owning typeface. The export endpoint and reconcile hook query by it. Read-only. |
| `original` | `relationship` |  | The source fontOriginal this was subsetted from, the reconcile key (one optimized per original). Read-only. |
| `weight` | `text` |  | CSS font-weight: a single step (400) or a variable range (100 900); served verbatim to next/font. Read-only. |
| `style` | `radio` |  | normal / italic. Read-only. |
| `isVariable` | `checkbox` |  | Whether this file is a variable font. Read-only. |
| `italCapable` | `checkbox` |  | This upright variable file ALSO carries italics via its axes (a true ital axis, or a negative slnt range) — the serving layers emit a second, italic @font-face from the same file unless the typeface has an explicit italic file. Read-only. |
| `obliqueAngle` | `number` |  | For slnt-based italics: the positive CSS oblique angle (deg) matching the axis extreme, e.g. 15 for slnt 0…-15. Read-only. |

**Hooks**: none; the bytes are derived and rebuilt from the originals on every `font` save.

## Globals — `fontSet`

Uploading a typeface doesn't put it on the site; it just adds it to the library. **`fontSet`** is
how you choose which uploaded typeface fills each family. `fontsPlugin()` registers it as a singleton
global under the **Assets** group with one single-relationship slot per family (four by default),
each filtered to its family so the admin only offers matching typefaces:

| Slot      | Picks the typeface used for… | Choices                            |
| --------- | ---------------------------- | ---------------------------------- |
| `sans`    | `var(--font-setSans)`        | `font` docs with `family: sans`    |
| `serif`   | `var(--font-setSerif)`       | `font` docs with `family: serif`   |
| `mono`    | `var(--font-setMono)`        | `font` docs with `family: mono`    |
| `display` | `var(--font-setDisplay)`     | `font` docs with `family: display` |

Pick one **Font** per slot and save; that selection is the source of truth both serving paths read.
Change it and the next download (or, in dev, the next refresh) swaps the served fonts, no code change.
Leave a family empty to fall back to whatever your CSS defines for that variable.

```ts
// or set it without the admin: server-side / in a migration
await payload.updateGlobal({ slug: 'fontSet', data: { sans: interId, mono: jetbrainsMonoId } })
```

Seeds set it the same way with `ref()` tokens (see [Seeding](#seeding)). Driving the selection some
other way? Pass `includeFontSet: false` to skip the global and resolve the active typefaces yourself.

**Custom families.** `families` is the **complete list**: whatever you pass replaces the four
defaults wholesale. It flows through everything in lockstep: the `family` options, these slots, the
export JSON keys, and the generated `font<Key>` / `--font-set<Key>` names.

```ts
fontsPlugin({
  families: [
    { key: 'sans' },                                              // label defaults to "Sans"
    { key: 'display' },
    { key: 'brand', label: 'Brand', fallback: 'Georgia, serif' }, // a new family
  ],
})
// EXACTLY these three; serif and mono are dropped. family options + fontSet slots
// sans/display/brand; exports fontSans/fontDisplay/fontBrand; vars --font-set{Sans,Display,Brand}.
```

To **add** a family while keeping the defaults, spread `DEFAULT_FONT_FAMILIES`:

```ts
import { fontsPlugin, DEFAULT_FONT_FAMILIES } from '@pro-laico/payload-fonts'

fontsPlugin({ families: [...DEFAULT_FONT_FAMILIES, { key: 'brand', fallback: 'Georgia, serif' }] })
```

`<DevFonts>` auto-discovers the slots from the `fontSet` global, so custom families just work; pass
`families` to it only if you set custom per-family `fallback` stacks and want the dev preview to match.

## Serving

The active fonts apply as the `--font-set{Sans,Serif,Mono,Display}` CSS variables, so your app just
uses `font-family: var(--font-setSans)`. Those variables are produced **two ways that never both
fire**: optimized in production, zero-config in development. Wire both in your root layout; each is
a no-op in the other environment:

```tsx
import config from '@payload-config'
import definitionFonts from '@/app/definition'
import { extractFonts } from '@pro-laico/payload-fonts'
import { DevFonts } from '@pro-laico/payload-fonts/DevFonts'

<html className={extractFonts(definitionFonts)}>   {/* production: next/font */}
  <head>
    <DevFonts config={config} definition={definitionFonts} />   {/* development: runtime */}
  </head>
</html>
```

**Production**

Self-hosted via [`next/font/local`](https://nextjs.org/docs/app/api-reference/components/font),
giving precise preloading, size-adjusted fallbacks, content-hashed static assets. A build step resolves the
`fontSet` selection and writes the fonts to disk:

- `GET /api/fonts/export`, gated by `PAYLOAD_SECRET` (Bearer), returns each active family's
  `fontOptimized` bytes.
- The `payload-fonts-download` CLI fetches that and writes `public/fonts/*.woff2` +
  `src/app/definition.ts`; `extractFonts` puts the generated classes (which define the `--font-set*`
  variables) on `<html>`.

```json
// package.json: set FONT_DOWNLOAD_URL (the running Payload URL) + PAYLOAD_SECRET
{ "scripts": { "prebuild": "payload-fonts-download" } }
```

`definition.ts` and `public/fonts/` are generated, so gitignore them. A `predev: payload-fonts-download`
writes an empty stub so the import resolves before your dev server is up (no file to hand-create).
Alternative: **commit an empty `definition.ts` as a baseline** and skip `predev` entirely — the download
still overwrites it on `prebuild` (the fonts-sandbox example takes this route).

The CLI reads these env vars. Only the first two are required:

| Env var                         | Default                   | What                                                                               |
| ------------------------------- | ------------------------- | ---------------------------------------------------------------------------------- |
| `FONT_DOWNLOAD_URL`             | —                         | **Required.** URL of the running Payload instance to fetch from.                   |
| `PAYLOAD_SECRET`                | —                         | **Required.** Bearer secret that authorizes the export endpoint.                   |
| `PAYLOAD_FONTS_OUTPUT_DIR`      | `./public/fonts`          | Where the downloaded WOFF2 files are written.                                      |
| `PAYLOAD_FONTS_DEFINITION_FILE` | `./src/app/definition.ts` | The generated `next/font/local` module.                                            |
| `PAYLOAD_FONTS_SRC_PREFIX`      | `../../public/fonts`      | `src` path in the generated `localFont()` calls, relative to the definition file.  |
| `PAYLOAD_FONTS_CSS_VAR_PREFIX`  | `--font-set`              | Prefix for the emitted CSS family variables; must match `<DevFonts cssVarPrefix>`. |
| `PAYLOAD_FONTS_ENDPOINT`        | `/api/fonts/export`       | Export endpoint path, resolved against the site URL.                               |
| `PAYLOAD_FONTS_ENV_FILE`        | `./.env`                  | Dotenv file loaded before reading env.                                             |
| `PAYLOAD_FONTS_VERBOSE`         | `false`                   | Print the full error on failure (also the `--verbose` / `-v` flag).                |

> **Failures empty the definition.** Any error during the download (a missing env var, an
> unreachable endpoint, an unexpected throw) resets `definition.ts` to an **empty** module so the
> build still compiles, rather than leaving a stale one that imports font files no longer on disk
> (a fresh checkout gitignores `public/fonts/`).

**Development**

`<DevFonts>` reads the active `fontSet` selection from Payload and inlines the matching `@font-face`

- `--font-set*` variables **at runtime**, so seeding or editing a font shows up on refresh with no
  build step. It renders nothing in production, and stands down in dev once `definition.ts` is
  populated, so running `payload-fonts-download` against your dev server previews the exact production
  path locally. (It's a server component; pass it your `@payload-config`.)

## How it works

The three collections and two serving paths fit together like this.

### The subset pipeline

You only ever edit the `font` typeface. Its uploaded files are stored raw in `fontOriginal` — the
**archive of truth** — and subsetted to served `fontOptimized` WOFF2s on every save. Keeping the
untouched originals is deliberate: the output is lossy (subsetted to your `charset`), so holding the
source bytes lets it be re-derived later — widen `charset`, and the next save re-subsets from the
originals with no re-upload. A delete cascades through both derived layers, and the whole flow runs in
the `font` collection's hooks (`optimizeFromOriginals` on save, `cleanupFontAssets` on delete), so
there's no separate build step to run and nothing to keep in sync by hand.

### One file, both styles

Some variable fonts carry their italics **inside the upright file** — a true `ital` axis, or a
`slnt` (slant) axis like Recursive's `0…-15`. Upload one of those to the `variable.upright` slot
and the optimize hook reads the axes (fontkit) and flags the served file `italCapable` (plus
`obliqueAngle` for slant-based ones). Every serving path — `DevFonts` / `buildFontFaceCss`, the
export endpoint, and the download CLI's `next/font/local` output — then emits **two faces from
the one file**: the upright, and an italic declared as `font-style: italic` (an `ital` axis) or
`font-style: oblique 15deg` (a `slnt` axis), which CSS maps onto the axis automatically. Your
`font-style: italic` styles just work — italic requests match oblique faces in font matching.
An explicit `variable.italic` file always wins: no synthesis when one exists.

### Revalidation & caching

How a `fontSet` change reaches the browser depends on the serving path, by design.

- **Production** bakes fonts at build time, so there's no runtime cache to bust and **no
  `revalidatePath`/`revalidateTag`**; that's the tradeoff for stock `next/font`. Publish a change by
  re-running the download and rebuilding (`payload-fonts-download` → `next build`). To automate it,
  trigger a redeploy from a `fontSetOverrides.hooks.afterChange` deploy hook. The export endpoint is
  `Cache-Control: no-store`, so each run sees the current selection.
- **Development** reads the live selection on every render, so just keep the route dynamic
  (`export const dynamic = 'force-dynamic'`) and an edit-then-refresh shows. The browser still caches
  the `/api/fontOptimized/file/…` font files, so hard-refresh after re-subsetting the *same* typeface.

## Seeding

`fontOriginal` is a plain Payload upload collection, so its raw font files seed **natively** via
[`@pro-laico/payload-seed`](/docs/plugins/payload-seed) like any image — no
asset marker, no glue. Each `font` typeface then **references** its originals with `ref('fontOriginal', …)`
— in its `variable` group (one file, every weight) or its `weights` rows (one file per weight) — and the
collection's `afterChange` hook subsets each referenced original into a served `fontOptimized` WOFF2.
Point the seed engine at both definitions and map `fontOriginal`'s source folder with `assetSubDirs`:

**payload.config.ts**

```ts
import { fontsPlugin } from '@pro-laico/payload-fonts'
import { seedPlugin } from '@pro-laico/payload-seed'

plugins: [
  fontsPlugin(),
  seedPlugin({
    definitions: [fontOriginals, fonts, fontSet],
    assetSubDirs: { fontOriginal: 'font' }, // font files live in <assetsDir>/font/, not /fontOriginal/
  }),
]
```

**fontOriginals.ts**

```ts
// src/seed/fontOriginals.ts: raw font files, seeded natively like any upload (files live in <assetsDir>/font/)
import { defineSeed } from '@pro-laico/payload-seed'

export const fontOriginals = defineSeed('fontOriginal', ({ file }) => [
  {
    _key: 'inter-variable',
    _file: file('InterVariable.woff2'), // assets/font/InterVariable.woff2 (via assetSubDirs)
  },
  {
    _key: 'inter-variable-italic',
    _file: file('InterVariable-Italic.woff2'), // assets/font/InterVariable-Italic.woff2
  },
  {
    _key: 'lora-400',
    _file: file('lora-400.woff2'), // assets/font/lora-400.woff2
  },
  {
    _key: 'lora-400-italic',
    _file: file('lora-400-italic.woff2'), // assets/font/lora-400-italic.woff2
  },
  {
    _key: 'lora-700',
    _file: file('lora-700.woff2'), // assets/font/lora-700.woff2
  },
  {
    _key: 'recursive-variable',
    _file: file('recursive-variable.woff2'), // assets/font/recursive-variable.woff2
  },
])
```

**fonts.ts**

```ts
// src/seed/fonts.ts: the three shapes — variable pair, static weights + italics, one-file-both-styles
import { defineSeed } from '@pro-laico/payload-seed'

export default defineSeed('font', ({ ref }) => [
  {
    _key: 'inter',
    title: 'Inter',
    family: 'sans',
    // variable pair: upright + italic files, each covering the whole weight axis (100–900)
    variable: {
      upright: ref('fontOriginal', 'inter-variable'),
      italic: ref('fontOriginal', 'inter-variable-italic'),
    },
  },
  {
    _key: 'lora',
    title: 'Lora',
    family: 'serif',
    // static files: one row per weight/style you actually use
    weights: [
      { weight: '400', style: 'normal', file: ref('fontOriginal', 'lora-400') },
      { weight: '400', style: 'italic', file: ref('fontOriginal', 'lora-400-italic') },
      { weight: '700', style: 'normal', file: ref('fontOriginal', 'lora-700') },
    ],
  },
  {
    _key: 'recursive',
    title: 'Recursive',
    family: 'display',
    // one file, BOTH styles: Recursive's axes carry wght 300–1000 and slnt 0…-15, so the hook
    // flags it ital-capable and the site serves an italic face from this same upload
    variable: { upright: ref('fontOriginal', 'recursive-variable') },
  },
])
```

**fontSet.ts**

```ts
// src/seed/fontSet.ts: pick the active typeface per family with ordinary ref() tokens
import { defineSeed } from '@pro-laico/payload-seed'

export const fontSet = defineSeed('fontSet', ({ ref }) => ({
  sans: ref('font', 'inter'),
  serif: ref('font', 'lora'),
}))
```

A reseed clears the `fontOriginal` and `font` collections via `payload.delete`, so the cascade removes
the old originals + optimized too (idempotent). See the
[seed plugin docs](/docs/plugins/payload-seed) for the full reference.

## Gotchas

A few things to keep in mind:

- **A bundled subsetter breaks font serving.** Without `serverExternalPackages: ['subset-font',
  'harfbuzzjs', 'fontkit']` in next.config, Next bundles the subsetter, the wasm breaks, and
  optimization skips — fonts save but never shrink. It's a Quickstart step for a reason. You'll
  hear about it: a dev-boot probe runs a real subset and logs a loud `[payload-fonts]` error with
  the fix when the wasm can't load (in production, the same error logs on the first font save),
  and a typeface whose **Served Files** count stays 0 in the admin is the same signal.
- **`families` replaces the defaults wholesale.** The list you pass is the *complete* list:
  `families: [{ key: 'brand' }]` drops sans/serif/mono/display entirely. Spread
  `DEFAULT_FONT_FAMILIES` to keep them and add yours.
- **`definition.ts` and `public/fonts/` are generated — gitignore them.** There's no file to
  hand-create or hand-edit; `payload-fonts-download` owns both. Any download failure resets
  `definition.ts` to an **empty** module so the build still compiles (fonts fall back to your CSS
  stacks rather than erroring).
- **The download CLI needs a running Payload.** `payload-fonts-download` fetches over HTTP from
  `FONT_DOWNLOAD_URL`, so that instance must be up and reachable while `prebuild` runs — a build with
  no reachable Payload gets the empty-definition fallback above.
- **Production font changes need a rebuild.** The build bakes the `fontSet` selection via `next/font`;
  there's no runtime cache to revalidate. Publish a swap by re-running the download + build (or wire a
  deploy hook via `fontSetOverrides`).
- **Subsetting is lossy to your `charset`.** Characters outside it aren't in the served WOFF2 and
  render in the fallback font. The originals are archived, so widen `charset` and re-save each
  typeface to re-derive — no re-upload.
- **One original per typeface, and variable XOR weights.** A save that references a `fontOriginal`
  already used by another typeface is rejected (it keeps cascade cleanup safe), as is mixing a
  variable file with per-weight files on the same typeface.
- **Changing `families` strands existing data.** Dropping a family removes its `fontSet` slot (the
  stored selection silently vanishes) and leaves `font` docs whose `family` value is no longer an
  option — they fail validation on their next save. Migrate those docs (or keep the old key) when you
  reshape the list.
- **The download CLI fails soft, and carries your secret.** Every handled failure writes the empty
  definition and exits 0 so the build proceeds — CI can't gate on "shipped without fonts", so watch
  the build log. And it sends `PAYLOAD_SECRET` as a bearer token to whatever `FONT_DOWNLOAD_URL`
  points at — keep that URL https and correct.

## Exports

| Export                   | From                                | What                                                                                                      |
| ------------------------ | ----------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `fontsPlugin`            | `@pro-laico/payload-fonts`          | The plugin factory.                                                                                       |
| `FontFamilyConfig`       | `@pro-laico/payload-fonts`          | The `{ key, label?, fallback? }` shape of a `families` entry.                                             |
| `DEFAULT_FONT_FAMILIES`  | `@pro-laico/payload-fonts`          | The built-in sans/serif/mono/display families (spread to extend them).                                    |
| `extractFonts`           | `@pro-laico/payload-fonts`          | Collect the generated `next/font` classes for the root `<html>` className.                                |
| `getActiveFontFaces`     | `@pro-laico/payload-fonts`          | Resolve the active `fontSet` selection to its served files; the read behind `<DevFonts>`, for custom UIs. |
| `ExportFontsResponse`    | `@pro-laico/payload-fonts`          | The shape of `GET /api/fonts/export`, for custom consumers.                                               |
| `DevFonts`               | `@pro-laico/payload-fonts/DevFonts` | The dev-only runtime font component.                                                                      |
| `payload-fonts-download` | `bin`                               | The CLI the production build runs to write fonts to disk.                                                 |
