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.
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:
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, // always defaults true; false makes the plugin a no-op
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
},
},
})slugrenames the collection; the plugin follows the rename through every reference it owns — relationship and join fields, hooks, endpoints, and the slug on its marker.overridesis aPartial<CollectionConfig>(orPartial<GlobalConfig>), merged onto the plugin's — not an allowlist of blessed keys. Whatever Payload's config has, you can set, exceptslug: it's the sibling key above, excluded here so a rename can't hide inoverrideswhere the plugin wouldn't follow it.optionsis the plugin's vocabulary for that collection — the admin cell, upload constraints, and so on. Kept apart fromoverridesso the two never bleed together.
Two more rules:
- One entry per collection, one axis per key. Where a collection is optional,
falsemeans this plugin ignores it —collections: { iconSet: false }drops the collectionpayload-iconswould register. A collection the plugin can't work without has nofalseform (there's nopayload-imageswithoutimages); each plugin's reference says which. - Optional sub-features read
false | Options. Notrueform;{}means "on, all defaults".prewarm: {}andprewarm: false— notprewarm: 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:
| Key | How it merges |
|---|---|
slug | Renames the collection (a direct key on the entry, alongside overrides). |
fields | Appended 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. |
hooks | Merged per phase. Yours run after the plugin's. |
access, admin, upload, custom | Shallow-merged, so the plugin's other keys survive. |
defaultPopulate, forceSelect | Merged as selects. |
| everything else | Replaced. |
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.
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-seedis the connective tissue — images, icons, and font files seed natively as uploads; Mux clips seed through acustom.seedAssetmarker the engine auto-discovers. One set ofseed.tsfiles bootstraps a whole site.payload-dev-toolslights up diagnostic panels for whatever it finds installed, reading each sibling's marker (payloadImages,payloadIcons,payloadFonts,payloadMux,payloadSeed,payloadRevalidate).payload-revalidatepicks up siblingcustom.revalidatemarkers 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:importmapThen 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:
| Plugin | Next.js | Why |
|---|---|---|
| payload-revalidate | Required, 16+ | It's a Next cache tool — the whole plugin is next/cache plus Cache Components. |
| payload-images | Required, 15+ | The transform endpoint, prewarming, and preset generation persist variants with Next's after(). |
| payload-dev-tools | Required, 15+ | The toolbar and dev pages are Next components (next/link, next/navigation, next/headers). |
| payload-icons | Partly, 15+ | The collection, admin, and SVG pipeline work in any Payload. The <Icon> component reads draftMode() and runtime miss-tracking uses after(), so rendering icons needs Next. |
| payload-fonts | Partly, 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-mux | No | Zero next/ imports, no next peer. Any Payload app. |
| payload-seed | No | Zero next/ imports, no next peer. Any Payload app. |
Each plugin's own page states this up front too.
Payload Plugins is not affiliated with Payload CMS in any capacity.