7.9 KiB
| tags | topic | sources | created | ||||
|---|---|---|---|---|---|---|---|
|
payloadcms |
|
2026-05-15 |
PayloadCMS — Plugin API
Overview
- A plugin is a function:
(incomingConfig: Config) => Config - Core contract is permanent — simple functions always work
- Advanced API adds execution ordering (
order) and typed cross-plugin communication (RegisteredPlugins) - Advanced API is experimental — safe to use but surface may change
- See wiki/payloadcms/plugins for ready-made plugins
Plugin Function Signature
Minimal (always works):
import type { Config, Plugin } from 'payload'
export const myPlugin =
(opts: MyOptions): Plugin =>
(incomingConfig: Config): Config => ({
...incomingConfig,
collections: [...(incomingConfig.collections || []), myCollection],
})
With definePlugin (recommended for published packages):
import { definePlugin } from 'payload'
export const myPlugin = definePlugin<MyOptions>({
slug: 'my-plugin', // unique identifier for cross-plugin communication
order: 10, // lower = runs first; default 0
plugin: ({ config, plugins, ...opts }) => ({
...config,
collections: [...(config.collections || []), myCollection],
}),
})
// Usage in payload.config.ts:
plugins: [myPlugin({ /* options */ })]
What Plugins Can Do
By spreading the incoming config and returning a modified version, plugins can:
- Add or modify collections and globals
- Inject fields into existing collections
- Register wiki/payloadcms/hooks (beforeChange, afterRead, etc.)
- Add custom endpoints
- Add custom admin views and components
- Extend
onInit(must call existingonInitfirst) - Add GraphQL queries/mutations
- Add
custommetadata to the config
Spread Syntax — Critical Rule
Always spread existing arrays/objects; never replace them:
// Collections
config.collections = [
...(config.collections || []),
newCollection,
]
// Globals
config.globals = [
...(config.globals || []),
newGlobal,
]
// Hooks (object — spread differently)
config.hooks = {
...(config.hooks || {}),
afterError: [...(config.hooks?.afterError || []), myHook],
}
// onInit — must chain, cannot spread
config.onInit = async (payload) => {
if (incomingConfig.onInit) await incomingConfig.onInit(payload)
// your logic here
}
Example Plugin
Full lifecycle example — adds a lastModifiedBy relationship field to all auth-enabled collections:
import type { Config, Plugin } from 'payload'
export const addLastModified: Plugin = (incomingConfig: Config): Config => {
const authCollections = incomingConfig.collections.filter(
(c) => Boolean(c.auth),
)
return {
...incomingConfig,
collections: incomingConfig.collections.map((collection) => ({
...collection,
fields: [
...collection.fields,
{
name: 'lastModifiedBy',
type: 'relationship',
relationTo: authCollections.map(({ slug }) => slug),
hooks: {
beforeChange: [
({ req }) => ({
value: req?.user?.id,
relationTo: req?.user?.collection,
}),
],
},
admin: { position: 'sidebar', readOnly: true },
},
],
})),
}
}
Execution Ordering with order
Negative → must run before everything (polyfills, normalization)
0 → default — no dependencies
10–50 → depends on collections/fields added by other plugins
100+ → must run last (audit, introspection, final-config plugins)
plugins: [
analyticsPlugin(), // order: 10 — runs second
basePlugin(), // order: 1 — runs first (regardless of array position)
]
Cross-Plugin Communication
Pattern: a plugin exposes its options; another plugin mutates those options before the first runs.
// writer plugin (order: 1) — runs first, injects into reader's options
export const writerPlugin = definePlugin({
slug: 'my-writer',
order: 1,
plugin: ({ config, plugins }) => {
plugins['my-reader']?.options?.items.push({ name: 'injected' })
return config
},
})
// reader plugin (order: 10) — runs second, sees injected item
export const readerPlugin = definePlugin<{ items: Array<{ name: string }> }>({
slug: 'my-reader',
order: 10,
plugin: ({ config, items }) => ({
...config,
custom: { ...config.custom, items: items.map((i) => i.name) },
}),
})
// payload.config.ts
plugins: [readerPlugin({ items: [{ name: 'user-provided' }] }), writerPlugin()]
// Result: reader sees ['user-provided', 'injected']
TypeScript: RegisteredPlugins Augmentation
Plugin packages can register their slug and options type so the plugins map is fully typed — no codegen, activated at import time:
// packages/my-plugin/src/index.ts
export type MyPluginOptions = { collections: string[] }
export const myPlugin = definePlugin<MyPluginOptions>({
slug: 'my-plugin',
order: 10,
plugin: ({ config, collections }) => ({ ...config }),
})
declare module 'payload' {
interface RegisteredPlugins {
'my-plugin': MyPluginOptions
}
}
Any project that imports my-plugin gets typed access in the plugins map — plugins['my-plugin']?.options?.collections is typed as string[].
Plugin Template
Scaffold a new plugin with the official template:
npx create-payload-app@latest --template plugin
Template structure:
/— root config (package.json, README, tsconfig)/src— plugin source code/dev— local Payload dev environment for testing
Start dev:
cd dev && pnpm dev # http://localhost:3000
Run tests (Jest):
cd dev && pnpm test
Seeding Data in Dev Environment
For development and testing, seed the database via PAYLOAD_SEED=true:
// dev/src/server.ts
if (process.env.PAYLOAD_SEED === 'true') {
await seed(payload)
}
// dev/src/seed.ts
export const seed = async (payload: Payload): Promise<void> => {
payload.logger.info('Seeding data...')
await payload.create({
collection: 'new-collection',
data: { title: 'Seeded title' },
})
}
Best Practices
- Enable/disable option — provide
enabled?: booleanso users can disable without uninstalling - TypeScript types — export
PluginTypesinterface with JSDoc comments for IDE support - Test suite — Jest in
dev/folder; integrate into GitHub CI - SemVer — major version should indicate Payload compatibility
- npm publish — publish with
payload-pluginGitHub topic for discoverability - Spread everything — never mutate incoming config directly; always spread
- Chain functions — for
onInit,onInit, etc. call the existing function first
Checking If a Plugin Is Installed
From outside a plugin:
const hasMySeoPlugin = config.plugins?.some((p) => p.slug === 'plugin-seo') ?? false
From inside a definePlugin function:
plugin: ({ config, plugins }) => {
if (plugins['plugin-seo']) {
// plugin-seo is installed — safe to mutate its options
}
return config
}
Gotchas
- Plugins execute after config validation but before defaults are merged — avoid relying on default values
- If you override
nameofparentorbreadcrumbsfields in nested-docs, setparentFieldSlug/breadcrumbsFieldSlugaccordingly onInitmust always chain the existing function or other plugins'onInitlogic is lost- Cross-plugin mutation works because options are resolved before any plugin executes — no re-execution needed
definePluginresult is a factory; call it with options to get aPlugin:myPlugin({ option: true })- Initialization order: validate → plugins execute → defaults merge → sanitize → final config