# payload-icons

URL: /docs/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.

`@pro-laico/payload-icons` makes it as easy to create a completely custom icon set as using an off-the-shelf
one like [Lucide](https://lucide.dev). Making asset sources like [The Noun Project](https://thenounproject.com)'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.

```bash
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](https://lucide.dev), 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`](/docs/plugins/payload-seed) — no helper, no provider.

![Icon set Default, rendered in the /dev/icons route](/screenshots/icons-default.png)

![Icon set Alternate, rendered in the /dev/icons route](/screenshots/icons-alternate.png)

> **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**

```ts
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):

```bash
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:

```tsx
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>`](#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.

**Reference**

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | When false, the plugin is a no-op; no collections are registered. |
| `iconOverrides` | `IconCollectionOverrides` |  | 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. |
| `includeIconSet` | `boolean` | `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. |
| `iconSetOverrides` | `IconSetCollectionOverrides` |  | 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. |
| `usagePanel` | `boolean` | `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. |
| `trackRequests` | `boolean` | `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. |
| `iconRequestOverrides` | `IconRequestCollectionOverrides` |  | Overrides for the iconRequest collection: additive fields/hooks (it has no built-in hooks). Only meaningful when trackRequests isn't false. |

**TypeScript**

```ts
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:

```tsx
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

**Reference**

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` |  | The name to render, matched against each entry's name in the active iconSet's iconsArray. Resolved server-side through the active set. _(required)_ |
| `fallback` | `string` | `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. |
| `...svgProps` | `SVGAttributes` |  | Any SVG attribute (className, style, width, …) spread onto the rendered svg, winning over the source's intrinsic attributes. |

**TypeScript**

```tsx
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):

```tsx
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`](https://cva.style) to turn `size` / `tone`
props into classes, one variant per line so the axes stay legible:

**Variants**

```tsx
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' },
})
```

**Usage**

```tsx
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](https://github.com/pro-laico/payload-plugins/tree/main/examples/icons-sandbox)
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**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `filename` | `upload` |  | The uploaded SVG (the upload). The admin useAsTitle. |
| `iconPreview` | `ui` |  | Theme-aware inline preview of the sanitized SVG (edit view + list cell), inheriting the admin theme's text color so icons stay visible in dark mode. |
| `svgString` | `code` |  | The cleaned, sanitized <svg>…</svg>, ready to inline. Read-only output; its list cell renders the themed preview. |
| `optimized` | `text` |  | Human-readable optimization report. Read-only output, shown once present. |
| `filesize` | `number` |  | Updated to the optimized byte size. |

**Hooks**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `formatSVGHook` | `beforeChange` |  | Optimizes + sanitizes each uploaded SVG, folding svgString / optimized / filesize into the doc. A no-op for changes that carry no new file. |

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](#icon-usage-detection) 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](#icon-usage-detection), 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`](#iconrequest) collection, with hit
  counts, last-seen, and a clear button.

Both are on with `iconsPlugin()`; pass `usagePanel: false` / `trackRequests: false` to omit either.

```bash
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 `import`ed 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](#icon-usage-detection) 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`](#plugin-options) 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')`):

```ts
// 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:

```ts
// 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:

```ts
// 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:

```ts
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`](https://github.com/pro-laico/payload-plugins/tree/main/examples/icons-sandbox)
app for a full working setup, or the [seed plugin docs](/docs/plugins/payload-seed) 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

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