payload-icons
A fully custom icon set in Payload CMS that's as easy to use as Lucide, and anyone in the admin can add to it.
@pro-laico/payload-icons makes it as easy to create a completely custom icon set as using an off-the-shelf
one like Lucide. Making asset sources like The Noun Project's
a much easier tool to utilize, and giving designers and clients the freedom to edit SVG's from Payload admin, without
breaking anything and maintaining optimizations.
You upload an .svg in the admin; the plugin optimizes, sanitizes, and themes it on save, then a single
<Icon name="…" /> server component drops it into any page as a real, inline <svg> that recolors
from CSS. No sprite sheet, no wrapper file, no untrusted markup reaching the browser.
pnpm add @pro-laico/payload-iconsWhat's included
A fully custom icon set, as easy to use as an off-the-shelf one:
- Add icons in the admin: upload an
.svgand the plugin optimizes, sanitizes, and themes it on save — designers and clients can extend the set without touching code or breaking anything. - One drop-in component:
<Icon name="arrow-right" />inlines a real, recolorable<svg>— as easy as Lucide, but it's your set. - Swappable icon sets: a set maps lookup names to icons and one set is active, so activating a different set re-skins every icon at once (a seasonal pack, a heavier redraw, an A/B test).
- Never ship a missing icon: the Requested icons panel scans your code and tracks runtime misses, surfacing exactly which icons you still need.
- Declarative seeding: icons seed natively through
@pro-laico/payload-seed— no helper, no provider.
Icon set Default, rendered in the /dev/icons route
Icon set Alternate, rendered in the /dev/icons route
Mental model. Icons are a shared pool of SVGs. An icon set maps a lookup name
(arrow-right) to an icon in that pool. One set is active, and <Icon name> resolves against
it, so the set entry's name is the lookup key, not the uploaded filename. Swap which set is
active and every icon re-skins at once. Uploading an icon isn't enough on its own; it renders once
an active, published set points a name at it.
Quickstart
Add the plugin
import { buildConfig } from 'payload'
import { iconsPlugin } from '@pro-laico/payload-icons'
export default buildConfig({
plugins: [iconsPlugin()],
})That registers the icon and iconSet collections plus a hidden iconRequest diagnostics
collection. Zero-config — then regenerate the admin import map (the icon-preview components are
registered by string path):
pnpm payload generate:importmapUpload an SVG
Add .svg files to the icon collection in the admin. Each is optimized and sanitized on save and
stored as svgString (the cleaned <svg>…</svg>) plus optimized (a short report, e.g. SVG optimized: 1234 to 567 bytes (54.1% reduction)). Uploading alone doesn't render anything yet; a set
has to point a name at it (next step).
Create a set, name each icon, then activate and publish
Add an iconSet. In the Icons tab, add one row per icon: type a name and select an uploaded
icon. The name is the lookup key <Icon name> uses and is independent of the uploaded filename,
so type whatever you'll reference in code (it's auto kebab-cased). In Settings, toggle the set
active, then publish it: sets are drafts-enabled by default, and the published frontend only
reads a published active set, so a draft-only set won't show. Activating another set later swaps
every icon at once (activating one deactivates the rest).
Render it
The active set is now resolvable. Drop the component into any server component or page:
import { Icon } from '@pro-laico/payload-icons/components/Icon'
// resolves `arrow-right` through the active set and inlines a real <svg>
<Icon name="arrow-right" className="size-6 text-primary" />That's the whole loop: upload → set → render. See <Icon> for props, fallbacks, and
styling.
Seeing the fallback glyph instead of your icon? Check, in order: (1) a set is active,
(2) it's published (not just saved as a draft), (3) the row name exactly matches the name
you pass to <Icon> (both are kebab-cased). A name with no match in the active set always renders
the fallback, never nothing.
Plugin options
iconsPlugin(options?) is the single entry point. One call registers the icon collection (upload
pipeline, on-save optimizer), the iconSet grouping collection, and the iconRequest diagnostics
collection. It's zero-config: the usage panel and request tracking are on by default; set them
false to omit.
Pass options to customize. The Reference tab is the interactive view; TypeScript is the same shape in code, every defaulted option written out.
enabledbooleandefault trueWhen false, the plugin is a no-op; no collections are registered.
iconOverridesIconCollectionOverridesOverrides for the icon upload collection. slug / adminGroup / access / upload replace the defaults; fields and hooks are APPENDED after the built-ins (never replacing them), so your beforeChange runs after formatSVGHook and sees the already-optimized SVG.
includeIconSetbooleandefault trueWhen false, the iconSet collection is not registered (only icon). Use when you want icons in the CMS but not the grouping/active-set concept.
iconSetOverridesIconSetCollectionOverridesOverrides for the iconSet collection: slug, live-preview wiring, set-level fields, per-row fields, a drafts toggle. Like iconOverrides, fields and hooks are APPENDED after the built-ins (so your beforeChange runs after the single-active hook), not replaced.
usagePanelbooleandefault trueThe IconSet "Requested icons" panel: shows which icons your code needs vs what a set provides (scanned live in dev, from the manifest in prod, plus runtime misses). Set false to omit it.
trackRequestsbooleandefault trueRegisters the iconRequest diagnostics collection and makes <Icon> record every name that fails to resolve at runtime (throttled, fire-and-forget), including dynamic names a static scan can't see, surfaced in the usage panel. Set false to omit; or force-off only the recorder at runtime with ICON_USAGE_TRACKING=false.
iconRequestOverridesIconRequestCollectionOverridesOverrides for the iconRequest collection: additive fields/hooks (it has no built-in hooks). Only meaningful when trackRequests isn't false.
import { iconsPlugin } from '@pro-laico/payload-icons'
// Defaulted options written out. This is what `iconsPlugin()` does with no args.
iconsPlugin({
enabled: true,
includeIconSet: true,
usagePanel: true, // the "Requested icons" panel; set false to omit
trackRequests: true, // runtime miss tracking; set false to omit
// overrides for the icon upload collection (slug / adminGroup / access / fields / hooks / upload):
iconOverrides: {
// slug: 'icon',
// access: { read: () => true },
// fields: [ /* … */ ],
// hooks: { /* … */ },
},
// overrides for the iconSet collection:
iconSetOverrides: {
// fields: [{ name: 'description', type: 'textarea' }],
// iconRowFields: [{ name: 'aliases', type: 'text', hasMany: true }],
},
})<Icon>
The frontend surface: one import, no wrapper file. The <Icon> server component resolves name
through the active set and inlines the matched icon as a real <svg>: your className/props win
over the SVG's intrinsic attributes, and it inherits CSS color via currentColor. Import from the
components/Icon subpath:
import { Icon } from '@pro-laico/payload-icons/components/Icon'
<Icon name="arrow-right" className="size-6 text-primary" />It's an async server component (it queries Payload), so render it in a server component / page.
It resolves your config from the plugin itself (stashed when Payload boots), so there's nothing to
wire. Only if it can render before anything has initialized Payload does it fall back to the
@payload-config alias — which, from a published package, needs
transpilePackages: ['@pro-laico/payload-icons'] in next.config. When name isn't in the active
set it renders the fallback you pass (or a small built-in warning glyph), never nothing.
Accessibility. <Icon> renders aria-hidden by default, which is right for decorative icons. When the
icon carries meaning (e.g. it's the only content of a button), pass aria-hidden={false} plus an
aria-label or a <title>; your props always win over the defaults.
Props
namestringrequiredThe name to render, matched against each entry's name in the active iconSet's iconsArray. Resolved server-side through the active set.
fallbackstringdefault built-in warning glyphOptional SVG string rendered when name doesn't match any icon in the active set. Omit it to use the built-in warning glyph.
...svgPropsSVGAttributesAny SVG attribute (className, style, width, …) spread onto the rendered svg, winning over the source's intrinsic attributes.
import { Icon } from '@pro-laico/payload-icons/components/Icon'
// `name` is the only required prop; it resolves through the active set.
<Icon
name="arrow-right"
className="size-6 text-primary"
// fallback={myCustomSvgString} // shown if the name isn't in the active set
/>Rendering it yourself
<Icon> is just getIconSvg (from the cache subpath, server-only and memoized per request) composed
with the pure extractSvg* helpers. Call getIconSvg(name, draft) yourself to render an icon in your
own markup (memoized, so a page calling it many times is still one query):
import { getIconSvg } from '@pro-laico/payload-icons/cache'
import { extractSvgContent, extractSvgProps } from '@pro-laico/payload-icons'
const svg = await getIconSvg('arrow-right') // draft defaults to false (published)
if (!svg) return null
return (
<svg
{...extractSvgProps(svg)}
className="size-6"
dangerouslySetInnerHTML={{ __html: extractSvgContent(svg) }}
/>
)It reads the active set's _status: 'published' rows on the published frontend (and the latest draft
in draft mode), so you never render an unpublished set.
Styling with CVA + Tailwind
Because the optimizer rewrites fills to currentColor and <Icon> forwards className straight
onto the <svg>, one source SVG recolors and resizes from class names alone, with no custom wrapper
needed. Pair <Icon> with class-variance-authority to turn size / tone
props into classes, one variant per line so the axes stay legible:
import { cva } from 'class-variance-authority'
const iconClass = cva('inline-block shrink-0', {
variants: {
size: {
xs: 'size-3.5',
sm: 'size-4',
base: 'size-5',
lg: 'size-6',
xl: 'size-8',
},
tone: {
current: '',
muted: 'text-muted-foreground',
primary: 'text-primary',
destructive: 'text-destructive',
},
},
defaultVariants: { size: 'base', tone: 'current' },
})import { Icon } from '@pro-laico/payload-icons/components/Icon'
// One source SVG, every variant; the cva className recolors and resizes it.
<Icon name="star" className={iconClass({ size: 'sm' })} />
<Icon name="star" className={iconClass({ size: 'lg', tone: 'primary' })} />
<Icon name="star" className={iconClass({ size: 'xl', tone: 'destructive' })} />The icons-sandbox example
ships the full version: a presentational Icon (CVA over the inline <svg>) plus a name-based
CmsIcon server wrapper, along with a showcase page that renders one source SVG across the variant,
size, and tone axes.
Collections
The plugin registers the icon upload collection (an Assets group), the iconSet grouping
collection (a Sets group), and a hidden iconRequest diagnostics collection (on by default;
disable with trackRequests: false). Icons accept SVG only, and every upload is sanitized before
storage, so a .svg is safe to inline straight onto a page.
icon
The upload collection you add icons to. Each file is optimized and sanitized on save by the
formatSVGHook beforeChange hook and stored inline as svgString. Read is public (icons are
frontend assets); writes are logged-in-admin only. The admin titles each doc by filename.
Fields
Prop
Type
Hooks
Prop
Type
On save, formatSVGHook runs svgo (loaded dynamically, so it never lands in a frontend or edge
bundle) and:
- Sanitizes untrusted SVGs: strips
<script>elements,on*handlers, andjavascript:URLs. The stored string is later inlined viadangerouslySetInnerHTML, so this runs even when geometry optimization is skipped. - Optimizes with svgo (
preset-default+ path/number cleanup, dimensions removed). - Themes by rewriting hard-coded
fill/stroketocurrentColor, so an icon takes its color from CSS. - Normalizes the
viewBox: tightened to the real path bounds and squared around the glyph's center, so mismatched source artboards render consistently.
Payload blocks SVG uploads by default (they're on its restricted-file-types list). This collection
is SVG-only and sanitizes every file before storage, so it opts in automatically via
upload.allowRestrictedFileTypes, with nothing to configure.
SVGs using transform / clip-path skip the geometry rewrite (it can't be applied safely) but
are still sanitized before storage.
iconSet
A named, ordered name → icon mapping into the shared icon pool, with a single-active toggle. Build
a second set (a different arrow-right, a heavier weight, a seasonal pack) and flip it active to
re-skin every icon at once, without touching the frontend. Writes and reads are logged-in-admin only;
drafts / versions are on by default (toggle with drafts). Lives under a Sets admin group.
- Settings tab: the
activetoggle andtitle(plus anyfieldsyou add, and the usage panel unlessusagePanel: false). - Icons tab: the
iconsArray, one row per icon, each aname(auto kebab-cased, what the frontend looks up, independent of the uploaded filename) and aniconupload relationship into theiconcollection.
Only one set is active at a time. Activating a set runs a beforeChange hook that deactivates the
others within the same status lane, so staging a new active set as a draft (with live
preview) doesn't disturb the live published set; the swap goes live only on publish. The hook runs
in the same transaction and rolls back on failure, so you never end up with two active sets.
It's self-contained: a plain checkbox, no external dependency.
iconRequest
On by default (disable with trackRequests: false). A hidden diagnostics collection: every icon name
that fails to resolve at runtime is recorded (throttled, fire-and-forget) with a hit count and first/lastRequestedAt
timestamps. It's the runtime counterpart to the static scan, capturing dynamic names a static pass can't
see. Surfaced in the IconSet usage panel, not browsed directly.
Icon Use Detection
Problem: code asks for a name the active set lacks → the fallback warning glyph, and it's easy to miss which icons a project actually needs.
Fix: the admin Requested icons panel (on by default) lists missing vs present names, from two sources:
- Static scan: greps your source for literal
<Icon name="…">, flagging missing names withfile:line. In dev the panel scans live, so there's nothing to set up or run. For production (source isn't on disk at runtime) runpayload-icons-scanin your build to write anicon-usage-manifest.jsonthe panel reads there. - Runtime: on by default too,
<Icon>logs names that fail to resolve at runtime (incl. dynamicname={slug}the scan can't see) into the hiddeniconRequestcollection, with hit counts, last-seen, and a clear button.
Both are on with iconsPlugin(); pass usagePanel: false / trackRequests: false to omit either.
payload-icons-scan # production/CI: scans ./src and ./app; -o sets the manifest pathScan sees literal names only: name="x", name={'x'}, name={`x`}; dynamic
name={expr} is trackRequests' job. In prod the panel reads icon-usage-manifest.json from cwd
(override with the ICON_USAGE_MANIFEST env var). Other component? --component Glyph. On
standalone / Vercel builds, a JSON file that's never imported isn't traced into the function
bundle — add it to outputFileTracingIncludes in next.config so the panel can read it there.
How it works
The active set, the read path, and revalidation fit together like this.
Resolving an icon
<Icon name="arrow-right" /> looks the name up in the active, published iconSet, finds the
icon doc it points at, and inlines that doc's stored svgString as a real <svg> — recolorable
through currentColor and sized by your own classes. The lookup reads through payload.find,
memoized per request, so repeating the same icon on a page costs one query. The name is the set
entry's name, not the uploaded filename, which is why swapping the active set re-skins every icon at
once. A name the active set doesn't cover renders the fallback warning glyph and is recorded for the
Requested icons panel.
Revalidation
The plugin does none. <Icon> reads via payload.find (memoized per request), so a statically
rendered page bakes the SVG at build time and won't reflect an edit (or an active-set swap) until
the route revalidates. Wire that yourself with afterChange / afterDelete hooks (e.g. calling
revalidatePath) through the collections' hooks option.
Seeding
Because icon is a standard upload collection, it seeds natively — no script, and (unlike
payload-mux) no custom.seedAsset marker. Seed it like any other collection with defineSeed:
each record carries its source SVG on the _file meta-key via the file() token. Drop your source
.svg files in the collection's folder under the seed assets dir (named after the slug, so
assets/icon/star.svg resolves from file('star.svg')):
// src/seed/icons.ts — each icon doc carries its SVG on `_file`
import { defineSeed } from '@pro-laico/payload-seed'
export default defineSeed('icon', ({ file }) => [
{
_key: 'star',
_file: file('star.svg'), // assets/icon/star.svg
},
{
_key: 'check',
_file: file('check.svg'), // assets/icon/check.svg
},
])Each doc is optimized on upload via the same formatSVGHook. An uploaded icon doesn't render on its
own, though — the frontend resolves <Icon name> through the active set, so seed an iconSet
that maps lookup names to those icons with ordinary ref('icon', …) tokens:
// src/seed/iconSets.ts — an active, published set wiring names to the seeded icons
import { defineSeed } from '@pro-laico/payload-seed'
export default defineSeed('iconSet', ({ ref }) => [
{
_key: 'default',
title: 'Default',
active: true, // the live set
_status: 'published', // visible to the non-draft frontend
iconsArray: [
{ name: 'star', icon: ref('icon', 'star') },
{ name: 'check', icon: ref('icon', 'check') },
],
},
])Icons are equally referenceable from any other doc — e.g. a page's icon relationship:
// src/seed/pages.ts
import { defineSeed } from '@pro-laico/payload-seed'
export default defineSeed('pages', ({ ref }) => [
{
_key: 'home',
title: 'Home',
icon: ref('icon', 'star'),
},
])Wire the definitions into the seed plugin as usual — icons precede the set and page that reference them:
import { iconsPlugin } from '@pro-laico/payload-icons'
import { seedPlugin } from '@pro-laico/payload-seed'
import icons from './seed/icons'
import iconSets from './seed/iconSets'
import pages from './seed/pages'
plugins: [iconsPlugin(), seedPlugin({ definitions: [icons, iconSets, pages] })]Because icon is a real collection, its seed records are type-checked and other docs reference them
with ref() — this package never imports the seed package, so the two stay decoupled. active: true
makes the set live and _status: 'published' makes it visible to the published frontend; see the
examples/icons-sandbox
app for a full working setup, or the seed plugin docs for the full reference.
Gotchas
A few things to keep in mind:
- Uploading an icon doesn't render it.
<Icon name>resolves through the active, published set. Seeing the fallback glyph? Check, in order: a set is active, it's published (not just a draft), and the row name matches thenameyou pass (both are kebab-cased). - The lookup name is the set entry's name, not the filename. Renaming an uploaded file changes
nothing; the
iconsArrayrow's name is the key your code references. - Use fill-based SVGs, not stroke-based. The optimizer themes filled glyphs by rewriting their
colors to
currentColor. A stroke-drawn icon (Lucide/Feather-stylefill="none" stroke="…") comes out with its enclosed shapes filled solid — a circle outline becomes a disk. Source solid-fill glyphs (Noun Project-style filled paths work; stroke sets don't). The upload detects the stroke signature and flags it in the doc'soptimizedreport. <Icon>is an async server component. It queries Payload, so it can't render inside a client component — render it in a server component and pass the output down, or resolve the SVG yourself withgetIconSvg.- No revalidation built in. A statically rendered page bakes the SVG at build time and won't
reflect an edit or an active-set swap until the route revalidates — wire
revalidatePath/revalidateTagyourself through the collections' hooks. - The static scan sees literal names only. A dynamic
name={expr}is invisible to it (that'strackRequests' job at runtime), and in production the usage panel needs thepayload-icons-scanmanifest written during your build.
Exports
| Export | From | What |
|---|---|---|
iconsPlugin | @pro-laico/payload-icons | The plugin factory and single entry point. |
extractSvgContent | @pro-laico/payload-icons | Pull the inner markup out of an svgString to inline it in your own <svg>. |
extractSvgProps | @pro-laico/payload-icons | Parse an svgString's root attributes (viewBox, etc.) onto your own <svg>. |
Icon | @pro-laico/payload-icons/components/Icon | The drop-in <Icon name="…" /> server component (resolves through the active set). |
getIconSvg | @pro-laico/payload-icons/cache | Resolve one icon name to its svgString through the active set (server-only, memoized). |
payload-icons-scan | @pro-laico/payload-icons | CLI that scans source for <Icon name> and writes the usage manifest. |