Payload Plugins
Plugins

Conventions

The patterns every @pro-laico plugin shares — one factory shape, zero-config defaults, and composition through plain Payload config so nothing ever imports another plugin.

For AI / LLMs: View Markdown

Every @pro-laico/* plugin is built the same way. Learn these once and each package feels familiar — same install, same shape, same escape hatches.

The factory shape

Every plugin is a (opts) => (config) => config factory, exported as both the default and a named export. Drop it into your Payload config's plugins array:

payload.config.ts
import { buildConfig } from 'payload'
import { pluginName } from '@pro-laico/<plugin>'

export default buildConfig({
  // ...
  plugins: [pluginName()],
})

Zero-config by default

Every option has a sensible default, so pluginName() with no arguments works out of the box. When you need more control, the raw building blocks — collections, hooks, fields, and admin components — are exported too, so you can wire them by hand instead of through the factory. Reach for the factory first; reach for the exports only when you outgrow it.

Defaults lean toward the feature being on. A plugin you installed should do its job without a checklist of opt-ins, so anything integral to the plugin isn't a toggle at all — payload-images always registers its transform endpoint, and payload-seed always registers its button (the ENABLE_SEED env var is the real switch there).

The options shape

Every plugin's options follow the same skeleton, so once you've configured one you can guess the next:

pluginName({
  enabled: true,        // defaults true; false makes the plugin a no-op
                        // (payload-dev-tools is the exception — it defaults to
                        //  NODE_ENV === 'development')
  collections: { … },   // one entry per collection the plugin registers
  globals: { … },       // …and per global, where the plugin has any
  // …the one or two items that ARE the plugin (payload-seed's `definitions`)
  options: { … },       // everything else — the plugin's own knobs
})

The split at the root is the whole idea: collections / globals describe what the plugin registers, and options holds the plugin's own behavior. A plugin whose entire job is one thing keeps that thing at the root next to options (payload-seed leaves definitions there); everything else lives under options.

Inside a collection

Every collections.<name> (and globals.<name>) entry is the same three-part shape, so a collection reads the same way in every plugin:

imagesPlugin({
  collections: {
    images: {
      slug: 'media',                          // rename it
      overrides: { access: { read: … } },     // Payload passthrough — anything CollectionConfig has
      options: { folders: true, focalUI: … }, // this plugin's own knobs for THIS collection
    },
  },
})
  • slug renames the collection; the plugin follows the rename through every reference it owns — relationship and join fields, hooks, endpoints, and the slug on its marker.
  • overrides is a Partial<CollectionConfig> (or Partial<GlobalConfig>), merged onto the plugin's — not an allowlist of blessed keys. Whatever Payload's config has, you can set, except slug: it's the sibling key above, excluded here so a rename can't hide in overrides where the plugin wouldn't follow it.
  • options is the plugin's vocabulary for that collection — the admin cell, upload constraints, and so on. Kept apart from overrides so the two never bleed together.

Two more rules:

  • One entry per collection, one axis per key. Where a collection is optional, false means this plugin ignores itcollections: { iconSet: false } drops the collection payload-icons would register. A collection the plugin can't work without has no false form (there's no payload-images without images); each plugin's reference says which.
  • Optional sub-features read false | Options. No true form; {} means "on, all defaults". prewarm: {} and prewarm: false — not prewarm: true.

payload-revalidate is the one plugin that doesn't register collections — it annotates the ones you already have. So its collections is keyed by your slugs and each entry is just the tracking config (there's no slug to rename or overrides to merge), with false to opt one out: collections: { posts: { idField: 'slug' }, drafts: false }.

How overrides merges

One algorithm, every plugin, every collection:

KeyHow it merges
slugRenames the collection (a direct key on the entry, alongside overrides).
fieldsAppended after the plugin's. A field named like one the plugin injects is a boot error naming the plugin, the collections key, and the field — rather than Payload's bare DuplicateFieldName.
hooksMerged per phase. Yours run after the plugin's.
access, admin, upload, customShallow-merged, so the plugin's other keys survive.
defaultPopulate, forceSelectMerged as selects.
everything elseReplaced.

The rule of thumb: anything the plugin depends on to work is merged, never clobbered; anything else is yours. Renaming plus overrides is the only way to reshape a plugin's collection — there's no separate option for attaching a plugin's fields to a collection you declared yourself.

Gating endpoints

Collection reads and writes are gated the Payload way — collections.<name>.overrides.access, in the table above. Everything a plugin exposes outside a collection — its HTTP endpoints — is gated one way too: a per-endpoint gate under the plugin's root options.access.

imagesPlugin({
  options: {
    access: {
      manage: ({ user }) => Boolean(user),  // the per-source admin endpoints
      serve: () => true,                     // public image serving
    },
  },
})
  • One key per endpoint. Each plugin's reference lists its endpoints and the key that gates each. The value is an EndpointAccess(req) => boolean | Promise<boolean>, the request-first cousin of Payload's collection Access. Return false to deny; leave a key unset to take the endpoint's own default.

  • The default is any logged-in user, except where an endpoint's job makes that wrong. Setting a gate replaces the default, machine ones included — so override those only if you terminate the check upstream.

    PluginKeyDefault
    payload-imagesmanageany logged-in user (the source doc's own read access still applies)
    payload-imagesservepublic — image serving has to answer anonymous traffic
    payload-muxuploadany logged-in user
    payload-muxwebhookMux's signature verification — a machine caller, no user session
    payload-muxrefreshany logged-in user
    payload-fontsexportPAYLOAD_SECRET bearer — a build calls it, no user session
    payload-iconsclearRequestsany logged-in user
    payload-seedrunany logged-in user (plus ENABLE_SEED, below)
    payload-revalidateinspectopen outside production; a logged-in user in production
    payload-dev-toolsdevpublic — the endpoints only register in development
  • Env switches are a separate axis. A gate answers who; an env var like ENABLE_SEED is a deployment kill-switch that decides whether the endpoint answers at all, and it runs first. payload-seed's /seed needs both ENABLE_SEED=true and a caller its run gate admits. Likewise enabled (and payload-revalidate's observe) decides whether an endpoint is registered in the first place — a gate only ever runs on an endpoint that exists.

EndpointAccess is exported from every plugin, for typing a gate you share across endpoints.

options.access gates HTTP endpoints. It does not gate rendered admin panels or pages — notably payload-dev-tools' access.dev closes /api/dev* but not the /dev pages, which are gated by enabled alone. Don't rely on a gate to hide a page.

Reading a plugin's config back

Each plugin stashes a typed marker on config.custom.payload<Name> describing what it resolved — slugs, endpoint paths, settings. Read it with the plugin's read<Name>Marker(config) helper rather than hand-casting custom:

import { readImagesMarker } from '@pro-laico/payload-images'

const { sourceSlug, basePath } = readImagesMarker(payload.config) ?? {}

Composition without coupling

No plugin imports another. They discover each other at build time through config.custom.* markers, so any combination works and any one can be installed alone.

  • payload-seed is the connective tissue — images, icons, and font files seed natively as uploads; Mux clips seed through a custom.seedAsset marker the engine auto-discovers. One set of seed.ts files bootstraps a whole site.
  • payload-dev-tools lights up diagnostic panels for whatever it finds installed, reading each sibling's marker (payloadImages, payloadIcons, payloadFonts, payloadMux, payloadSeed, payloadRevalidate).
  • payload-revalidate picks up sibling custom.revalidate markers to know what to bust.

Composition is plain Payload config — there's no shared runtime, no plugin registry, and no install order to get right.

The Assets admin group

The asset collections — images, icons, fonts, and Mux video — share a single Assets group in the admin sidebar, so your media stays together no matter how many of these plugins you install.

When to run generate:importmap

Any plugin that registers admin components by string path needs the import map regenerated so Payload can resolve them. That covers the images focal/preset UI, the seed button, the Mux uploader, and the icons preview. After installing one of these, run:

pnpm payload generate:importmap

Then restart the dev server so the new components load.

Requirements

Payload ^3 and React 19 for every package. The packages ship in lockstep under one version, but each works standalone — you never need to install more than the one you want.

Next.js is per-plugin, not a blanket requirement:

PluginNext.jsWhy
payload-revalidateRequired, 16+It's a Next cache tool — the whole plugin is next/cache plus Cache Components.
payload-imagesRequired, 15+The transform endpoint, prewarming, and preset generation persist variants with Next's after().
payload-dev-toolsRequired, 15+The toolbar and dev pages are Next components (next/link, next/navigation, next/headers).
payload-iconsPartly, 15+ with cacheComponentsThe collection, admin, and SVG pipeline work in any Payload. Rendering needs Next: the active-set read is a 'use cache' entry and runtime miss-tracking uses after().
payload-fontsPartly, 15+Uploading, subsetting, and exporting work anywhere. The download CLI writes a next/font/local module, so the serving story is Next-shaped — without it, take the exported .woff2 files and write your own @font-face.
payload-muxNoZero next/ imports, no next peer. Any Payload app.
payload-seedNoZero next/ imports, no next peer. Any Payload app.

Each plugin's own page states this up front too.

Prerendering a page that reads Payload

Worth knowing before you cache anything, because it isn't a plugin behaviour — it's how Payload and Next fit together.

Next decides a subtree is dynamic when it sees fetch, cookies(), headers(), or searchParams. It cannot see a read through Payload's Local API. So an uncached payload.find in a component is invisible to it: during a build the promise simply resolves, and whatever the database held at that moment is frozen into the HTML. The fix is to cache the read ('use cache' + cacheTag) and let a write bust the tag — which is what payload-revalidate automates, and what the example apps in this repo do. Reach for connection() only when a page must genuinely re-read on every request, such as a diagnostics view.

One consequence catches people out: once a page prerenders a Payload read, the build needs a database with a schema. Payload pushes the schema for you in development, but never under NODE_ENV=production, where it expects migrations instead. A production app already satisfies this — it builds against its real database, with migrations run as part of the deploy.

The example apps in this repo hold nothing but seed data, so they do the simpler equivalent: their prebuild runs the seed. That boots Payload outside production, which pushes the schema on the way, and leaves the build real content to prerender. Change the seed, reseed — there is nothing to migrate.

Payload Plugins is not affiliated with Payload CMS in any capacity.

On this page