payload-fonts
Choose your site's typefaces in the CMS and self-host them at next/font speed — every upload auto-subset to a lean WOFF2.
@pro-laico/payload-fonts lets editors upload typefaces to Payload and self-hosts them through next/font/local.
You get Next.js local-font performance — preloaded, size-adjusted, no third-party CDN — while picking your sans / serif / mono / display from the admin. Every uploaded file is subsetted to a lean, web-ready WOFF2 on save, with no manual fontkit tooling.
pnpm add @pro-laico/payload-fontsWhat's included
- Automatic optimization: every uploaded font is subsetted to a lean, web-ready WOFF2 on save — smaller files, no 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. - Baked in dev and prod, one path: a build step bakes
next/fontfrom the live selection — the same in development and production. Need to preview a font without a rebuild? Mount<PreviewFonts>, the live escape hatch, in that one context. - Declarative seeding: seed typefaces like any other doc through
@pro-laico/payload-seed.
Fonts rendered from CMS in the /dev/fonts route
Requirements
Self-hosting fonts through next/font needs a few one-time wiring steps. Each maps to a Quickstart step below.
| Requirement | Why |
|---|---|
| Next.js App Router, 15+ | For serving. The package itself imports nothing from next — the download CLI writes a module that does (import localFont from 'next/font/local'), which your root layout imports. Uploading, subsetting, and exporting .woff2 work in any Payload app; without Next, take the exported files and write your own @font-face. |
A next.config change | The subsetter (subset-font / harfbuzzjs / fontkit) must be externalized, or the wasm breaks and optimization silently skips (Step 2). |
| Root-layout wiring | extractFonts on <html> — the baked serving path (Step 4). |
predev + prebuild scripts | payload fonts:download writes the fonts + src/app/definition.ts your layout imports (Step 3). It reads the database directly — no running site needed. |
generate:importmap | The plugin registers admin components; regenerate the import map after installing (Step 3). |
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:
// next.config.mjs
import { withPayload } from '@payloadcms/next/withPayload'
const nextConfig = { serverExternalPackages: ['subset-font', 'harfbuzzjs', 'fontkit'] }
export default withPayload(nextConfig)Skip this and Next bundles the subsetter, the wasm breaks, and optimization is skipped — fonts save but never shrink. See Troubleshooting.
Generate the fonts and wire the build scripts
Production self-hosts via next/font/local. payload fonts:download resolves the fontSet
selection and writes public/fonts/*.woff2 + src/app/definition.ts — the file your layout imports
in the next step:
// package.json
{
"scripts": {
"build": "next build",
"predev": "payload fonts:download",
"prebuild": "payload fonts:download",
"generate:types": "payload generate:types",
"generate:importmap": "payload generate:importmap"
}
}Why predev too. The baked path is the same in dev and production, so wire the download on
both predev and prebuild: it keeps definition.ts current before either server starts, and
regenerates it on every dev boot so there's no file to hand-create. (Previewing a live selection
without a rebuild is what <PreviewFonts> is for — see Serving.)
Required: regenerate the import map. The plugin registers admin components. Run
pnpm payload generate:importmap and restart the dev server, or the admin font fields render blank.
It reads the database through Payload's Local API, so it needs nothing running and no extra env
beyond what your Payload app already has (DATABASE_URI, PAYLOAD_SECRET). Fonts on S3 work too —
the read goes through your collection's own storage adapter.
Gitignore both src/app/definition.ts and public/fonts/, and add the generator to
postinstall. They are a snapshot of your database, so committing them means every font change
produces a diff, and whoever last ran a build decides what the repo contains.
{ "scripts": { "postinstall": "payload fonts:download", "predev": "payload fonts:download", "prebuild": "payload fonts:download" } }The usual worry — that ignoring a file the layout imports breaks a fresh clone — doesn't apply:
every failure path writes an empty definition instead of failing. No database, no
PAYLOAD_SECRET, an unreachable host: you get Font download failed — wrote an empty definition so the build can proceed, valid TypeScript, and a site with fallback fonts. postinstall is what
makes the file exist before anyone opens their editor, so the import never shows as missing.
Building somewhere that can't reach the database? Use the payload-fonts-download CLI instead:
it fetches the same fonts over HTTP from a running site, and needs FONT_DOWNLOAD_URL plus a
matching PAYLOAD_SECRET. That instance must be up and reachable while the script runs — if it
isn't, the CLI writes an empty definition and the build ships with no fonts. See
Serving and
Reference.
Wire it into your layout
Put the baked next/font classes on <html> with extractFonts — the same in dev and production:
import definitionFonts from '@/app/definition'
import { extractFonts } from '@pro-laico/payload-fonts'
<html className={extractFonts(definitionFonts)}>
{/* … */}
</html>The @/app/definition import only resolves once Step 3 has run at least once (predev writes it).
To preview a live font selection without a rebuild — a preview route, a font playground — mount
<PreviewFonts> in that one context instead. See Serving.
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 } */Upload a typeface and pick it
Nothing renders until you choose a font in the admin. Go to Assets → Font and upload a typeface,
then open the fontSet global and pick it per family. Refresh, and your font is on the page.
See Collections for the font upload shape and the fontSet global.
Explore
Serving
The baked next/font path, the PreviewFonts escape hatch, and the download CLI's environment variables.
Collections
The font / fontOriginal / fontOptimized collections, the fontSet global, and custom families.
How it works
The subset pipeline, one-file-both-styles italics, revalidation, and seeding.
Troubleshooting
Fonts not shrinking, blank admin fields, stale fonts, and production rebuilds.
Reference
Plugin options, environment variables, CLI commands, endpoints, and exports.
Plugin options
Zero-config by default — every option has a sensible default. See Reference for options, env vars, CLI, and exports.
Troubleshooting
Fix a fallback glyph, a solid-blob icon, a blank admin preview, or an empty requested-icons panel — symptom-first, with the exact strings to search for.
Serving fonts
How the fonts you pick reach the browser: the baked next/font path that ships your site, the PreviewFonts escape hatch for previewing a live selection, plus every env var and prop that tunes them.