Payload Plugins
Plugins

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.

For AI / LLMs: View Markdown

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

What's included

A fully custom icon set, as easy to use as an off-the-shelf one:

  • Add icons in the admin: upload an .svg and 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 routeIcon set Default, rendered in the /dev/icons route

Icon set Alternate, rendered in the /dev/icons routeIcon 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:importmap

Upload 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 true

When false, the plugin is a no-op; no collections are registered.

iconOverridesIconCollectionOverrides

Overrides 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 true

When false, the iconSet collection is not registered (only icon). Use when you want icons in the CMS but not the grouping/active-set concept.

iconSetOverridesIconSetCollectionOverrides

Overrides 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 true

The 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 true

Registers 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.

iconRequestOverridesIconRequestCollectionOverrides

Overrides 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

namestringrequired

The 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 glyph

Optional SVG string rendered when name doesn't match any icon in the active set. Omit it to use the built-in warning glyph.

...svgPropsSVGAttributes

Any 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, and javascript: URLs. The stored string is later inlined via dangerouslySetInnerHTML, 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/stroke to currentColor, 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 active toggle and title (plus any fields you add, and the usage panel unless usagePanel: false).
  • Icons tab: the iconsArray, one row per icon, each a name (auto kebab-cased, what the frontend looks up, independent of the uploaded filename) and an icon upload relationship into the icon collection.

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 with file: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) run payload-icons-scan in your build to write an icon-usage-manifest.json the panel reads there.
  • Runtime: on by default too, <Icon> logs names that fail to resolve at runtime (incl. dynamic name={slug} the scan can't see) into the hidden iconRequest collection, 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 path

Scan 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 the name you pass (both are kebab-cased).
  • The lookup name is the set entry's name, not the filename. Renaming an uploaded file changes nothing; the iconsArray row'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-style fill="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's optimized report.
  • <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 with getIconSvg.
  • 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 / revalidateTag yourself through the collections' hooks.
  • The static scan sees literal names only. A dynamic name={expr} is invisible to it (that's trackRequests' job at runtime), and in production the usage panel needs the payload-icons-scan manifest written during your build.

Exports

ExportFromWhat
iconsPlugin@pro-laico/payload-iconsThe plugin factory and single entry point.
extractSvgContent@pro-laico/payload-iconsPull the inner markup out of an svgString to inline it in your own <svg>.
extractSvgProps@pro-laico/payload-iconsParse an svgString's root attributes (viewBox, etc.) onto your own <svg>.
Icon@pro-laico/payload-icons/components/IconThe drop-in <Icon name="…" /> server component (resolves through the active set).
getIconSvg@pro-laico/payload-icons/cacheResolve one icon name to its svgString through the active set (server-only, memoized).
payload-icons-scan@pro-laico/payload-iconsCLI that scans source for <Icon name> and writes the usage manifest.

On this page