Payload Plugins
Pluginspayload-fonts

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.

For AI / LLMs: View Markdown

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

What'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/font from 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 routeFonts 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.

RequirementWhy
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 changeThe subsetter (subset-font / harfbuzzjs / fontkit) must be externalized, or the wasm breaks and optimization silently skips (Step 2).
Root-layout wiringextractFonts on <html> — the baked serving path (Step 4).
predev + prebuild scriptspayload fonts:download writes the fonts + src/app/definition.ts your layout imports (Step 3). It reads the database directly — no running site needed.
generate:importmapThe 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.

package.json
{ "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

Plugin options

Zero-config by default — every option has a sensible default. See Reference for options, env vars, CLI, and exports.

On this page