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, // 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
},
},
})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.
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 collectionAccess. Returnfalseto 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.
Plugin Key Default 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_SECRETbearer — a build calls it, no user sessionpayload-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_SEEDis a deployment kill-switch that decides whether the endpoint answers at all, and it runs first.payload-seed's/seedneeds bothENABLE_SEED=trueand a caller itsrungate admits. Likewiseenabled(andpayload-revalidate'sobserve) 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-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+ with cacheComponents | The 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-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.
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.