Payload Plugins
Plugins

payload-fonts

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

For AI / LLMs: View Markdown

@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.

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 — 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 — upload the raw files to fontOriginal and reference them from the typeface.

Fonts rendered from CMS in the /dev/fonts routeFonts rendered from CMS in the /dev/fonts route

Quickstart

Add the plugin

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

// 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.
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:

@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):

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

# .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.

enabledbooleandefault true

When false, the plugin is a no-op and registers nothing.

charset'latin' | 'latin-ext' | stringdefault '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.

familiesFontFamilyConfig[]default sans / serif / mono / display

Font slots. The COMPLETE list: it replaces the four defaults wholesale (spread DEFAULT_FONT_FAMILIES to keep them). Each entry:

keystringrequired

Family id. Becomes a family option, a fontSet slot, and (capitalised) font<Key> / --font-set<Key>.

labelstringdefault capitalised key

Admin label for the family option and fontSet slot.

fallbackstringdefault generic sans stack

CSS fallback appended after the served family in the family variable.

includeFontSetbooleandefault 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).

fontOverridesPartial<CollectionConfig>

Merged onto the visible font typeface collection, e.g. tighter access.

fontOriginalOverridesPartial<CollectionConfig>

Merged onto the hidden fontOriginal upload collection, e.g. an upload.staticDir or a client-uploads (direct-to-Blob) adapter.

fontOptimizedOverridesPartial<CollectionConfig>

Merged onto the hidden fontOptimized upload collection (the served WOFF2s), e.g. an upload.staticDir.

fontSetOverridesPartial<GlobalConfig>

Merged onto the fontSet global.

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 fontfontOriginal (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

Prop

Type

Hooks

Prop

Type

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

Prop

Type

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:

SlotPicks the typeface used for…Choices
sansvar(--font-setSans)font docs with family: sans
serifvar(--font-setSerif)font docs with family: serif
monovar(--font-setMono)font docs with family: mono
displayvar(--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.

// 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). 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.

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:

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:

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>

Self-hosted via next/font/local, 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>.
// 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 varDefaultWhat
FONT_DOWNLOAD_URLRequired. URL of the running Payload instance to fetch from.
PAYLOAD_SECRETRequired. Bearer secret that authorizes the export endpoint.
PAYLOAD_FONTS_OUTPUT_DIR./public/fontsWhere the downloaded WOFF2 files are written.
PAYLOAD_FONTS_DEFINITION_FILE./src/app/definition.tsThe generated next/font/local module.
PAYLOAD_FONTS_SRC_PREFIX../../public/fontssrc path in the generated localFont() calls, relative to the definition file.
PAYLOAD_FONTS_CSS_VAR_PREFIX--font-setPrefix for the emitted CSS family variables; must match <DevFonts cssVarPrefix>.
PAYLOAD_FONTS_ENDPOINT/api/fonts/exportExport endpoint path, resolved against the site URL.
PAYLOAD_FONTS_ENV_FILE./.envDotenv file loaded before reading env.
PAYLOAD_FONTS_VERBOSEfalsePrint 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/).

<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.)
configSanitizedConfigrequired

Your Payload config, the same @payload-config import you pass to getPayload.

definitionRecord<string, { variable?: string }>

The generated next/font definition. When it already has fonts, DevFonts stands down and lets next/font take over; omit it and DevFonts always renders in dev.

cssVarPrefixstringdefault '--font-set'

CSS family-variable prefix; must match the download CLI cssVariablePrefix.

fontSetSlugstringdefault 'fontSet'

Slug of the font-selection global.

optimizedSlugstringdefault 'fontOptimized'

Slug of the optimized (served) upload collection.

familiesFontFamilyConfig[]default auto-discovered

Optional. The slots are auto-discovered from the fontSet global, so you only need this to match custom per-family fallback stacks in the dev preview.

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

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/
  }),
]
// 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
  },
])
// 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') },
  },
])
// 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 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

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

On this page