Payload Plugins
Pluginspayload-fonts

Troubleshooting

Fix the common payload-fonts failures fast — blank admin fields, fonts that never shrink, changes that don't show, and the generated files.

For AI / LLMs: View Markdown

Symptom-first. Find your failure in bold, read the cause, apply the fix.

Admin font field or component is blank ("not found in import map"). The plugin registers new admin components by string path, and the import map is stale. Run pnpm payload generate:importmap, then restart the dev server.

Upload is rejected with Invalid file type: 'application/octet-stream', or the file picker greys out every font. Windows registers no content type for .ttf, .otf, .woff, or .woff2, so the browser reports application/octet-stream and the upload gate — which checks the browser-reported type — turns the file away. The same font uploads fine from macOS, which supplies the type via UTI. Update: fontOriginal's whitelist now includes application/octet-stream and the file extensions, so the upload lands and the picker stops greying fonts out. On an older version, add the same strings via collections.fontOriginal.overrides.upload.mimeTypes.

Uploaded a font but nothing renders on the page. Uploading a typeface only adds it to the library — it isn't selected yet. Open the fontSet global, pick a Font for the family you want, and save. See The fontSet global.

Fonts save but never shrink; a typeface's Served Files count stays 0. Without serverExternalPackages: ['subset-font', 'harfbuzzjs', 'fontkit'] in next.config, Next bundles the subsetter, the wasm breaks, and optimization silently skips. Add the three packages to serverExternalPackages (a Quickstart step). 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. A typeface whose Served Files count stays 0 in the admin is the same signal.

A save (or seed) warns could not read original … that weight will NOT be served. The subsetter couldn't fetch the original's bytes back from storage. On CDN-backed storage (Vercel Blob), a freshly uploaded file can take up to ~60 seconds to become readable, so the plugin retries fresh originals (created in the last two minutes) for over a minute — with cache-busted reads, since a CDN can briefly go on serving a cached 404 — before giving up; local-disk collections fail fast, because there a miss means the file is genuinely gone. If the warning still fires, the file really is missing or unreadable: re-save the typeface (the originals are archived, so a transient miss re-derives cleanly), or re-upload the original.

Re-subsetted the same typeface but dev still shows the old font. The browser cached the /api/fontOptimized/file/… font file. Hard-refresh.

The families you passed dropped sans / serif / mono / display. options.families is the complete list — it replaces the four defaults wholesale, so options: { families: [{ key: 'brand' }] } drops them all. Spread DEFAULT_FONT_FAMILIES to keep them and add yours:

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

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

Reshaping families broke 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.

definition.ts and public/fonts/ keep showing up as git changes. Both are generated — payload-fonts-download owns them; there's no file to hand-create or hand-edit. Gitignore src/app/definition.ts and public/fonts/.

payload fonts:download dies with ENOENT node:crypto (or another node: builtin). Not this plugin — it's every custom payload <command> on Payload ≤ 3.85.1 with Node 23.5+. Payload wraps its CLI in tsx, whose namespaced importer leaked a ?namespace=… query onto node: builtins under Node's newer synchronous module hooks, so Node tried to open node:crypto as a file. Upgrade to payload@>=3.85.2, which fixes it upstream (payloadcms/payload#16949). If you're pinned to an older Payload, payload --use-swc fonts:download (with @swc-node/register installed) sidesteps tsx, or use the HTTP payload-fonts-download CLI, which doesn't go through Payload's bin at all.

The build shipped with no fonts — everything falls back to your CSS stacks. You're on the HTTP CLI (payload-fonts-download), which fetches from FONT_DOWNLOAD_URL, so that Payload instance must be up and reachable while prebuild runs. When it can't reach it, the CLI resets definition.ts to an empty module and exits 0 so the build still compiles. Switch prebuild to payload fonts:download — it reads the database through the Local API, so there's no site to be up, no URL, and no secret to match. Keep the HTTP CLI only if your build box genuinely can't reach the database; then make sure Payload is running during prebuild and watch the build log (a production build that ends up with no fonts now says so loudly, though it still exits 0).

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". And the CLI sends PAYLOAD_SECRET as a bearer token to whatever FONT_DOWNLOAD_URL points at — keep that URL https and correct.

A production font change didn't show. The build bakes the fontSet selection via next/font; there's no runtime cache to revalidate. Publish a swap by re-running the download and rebuilding (payload fonts:downloadnext build), or wire a deploy hook via globals.fontSet. See Revalidation & caching.

Some characters render in the fallback font. Subsetting is lossy to your charset — characters outside it aren't in the served WOFF2. The originals are archived, so widen charset and re-save each typeface to re-derive. No re-upload.

A save is rejected for a shared original, or for mixing variable and weights. One fontOriginal belongs to exactly one typeface (it keeps cascade cleanup safe), and a typeface uses a variable file XOR per-weight files, never both. Give the second typeface its own upload, and pick one shape per typeface. See font.