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.
pnpm add @pro-laico/payload-fontsWhat'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/fontfor production. The two paths never both fire. - Declarative seeding: seed typefaces like any other doc through
@pro-laico/payload-seed— upload the raw files tofontOriginaland reference them from the typeface.
Fonts 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 productionnext/fontclasses.<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 tokenPlugin 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 trueWhen 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:
keystringrequiredFamily id. Becomes a family option, a fontSet slot, and (capitalised) font<Key> / --font-set<Key>.
labelstringdefault capitalised keyAdmin label for the family option and fontSet slot.
fallbackstringdefault generic sans stackCSS fallback appended after the served family in the family variable.
includeFontSetbooleandefault trueRegister 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 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
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:
| 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.
// 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 byPAYLOAD_SECRET(Bearer), returns each active family'sfontOptimizedbytes.- The
payload-fonts-downloadCLI fetches that and writespublic/fonts/*.woff2+src/app/definition.ts;extractFontsputs 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 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/).
<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 oncedefinition.tsis populated, so runningpayload-fonts-downloadagainst your dev server previews the exact production path locally. (It's a server component; pass it your@payload-config.)
configSanitizedConfigrequiredYour 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-discoveredOptional. 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 stocknext/font. Publish a change by re-running the download and rebuilding (payload-fonts-download→next build). To automate it, trigger a redeploy from afontSetOverrides.hooks.afterChangedeploy hook. The export endpoint isCache-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. familiesreplaces the defaults wholesale. The list you pass is the complete list:families: [{ key: 'brand' }]drops sans/serif/mono/display entirely. SpreadDEFAULT_FONT_FAMILIESto keep them and add yours.definition.tsandpublic/fonts/are generated — gitignore them. There's no file to hand-create or hand-edit;payload-fonts-downloadowns both. Any download failure resetsdefinition.tsto 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-downloadfetches over HTTP fromFONT_DOWNLOAD_URL, so that instance must be up and reachable whileprebuildruns — a build with no reachable Payload gets the empty-definition fallback above. - Production font changes need a rebuild. The build bakes the
fontSetselection vianext/font; there's no runtime cache to revalidate. Publish a swap by re-running the download + build (or wire a deploy hook viafontSetOverrides). - 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 widencharsetand re-save each typeface to re-derive — no re-upload. - One original per typeface, and variable XOR weights. A save that references a
fontOriginalalready 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
familiesstrands existing data. Dropping a family removes itsfontSetslot (the stored selection silently vanishes) and leavesfontdocs whosefamilyvalue 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_SECRETas a bearer token to whateverFONT_DOWNLOAD_URLpoints 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. |