docs/plugins/plugin-api.mdx
Payload's plugin system is built around a simple contract: a plugin is a function that receives a config and returns a modified config. That simplicity is intentional and permanent — the basics will never change.
This page covers the advanced plugin API that makes plugins more powerful: execution ordering via order, typed cross-plugin communication via RegisteredPlugins, and the definePlugin helper that ties it all together.
The plain function form is unchanged and will always be supported:
import type { Config } from 'payload'
export const myPlugin =
(opts: MyOptions) =>
(config: Config): Config => ({
...config,
collections: [...(config.collections || []), myCollection],
})
Everything below builds on top of this — none of it is required for simple plugins.
definePlugin — recommended for published pluginsdefinePlugin replaces the boilerplate of manually attaching slug, order, and options to the function after the fact. Your plugin function receives a single object containing config, a plugins map, and any user-provided options spread directly in:
export const seoPlugin = definePlugin<SEOPluginOptions>({
slug: 'plugin-seo',
order: 10,
plugin: ({ config, plugins, collections }) => ({
...config,
collections: [...(config.collections || []), seoCollection],
}),
})
Import it from payload:
import { definePlugin } from 'payload'
The result of definePlugin is a factory function — call it with your options to get a Plugin:
// payload.config.ts
plugins: [seoPlugin({ collections: ['pages', 'posts'] })]
orderBy default, plugins execute in the order they appear in the plugins array. Setting order lets you declare execution order explicitly, regardless of array position.
Lower order values run first. The default is 0.
// This plugin runs first (order 1), even though it's listed second
plugins: [
analyticsPlugin({ trackingId: 'UA-...' }), // order 10 — runs second
basePlugin(), // order 1 — runs first
]
Settle on a convention so the ecosystem converges:
| Range | Use case |
|---|---|
| Negative | Must run before everything — config normalization, polyfills |
0 | Default — no dependencies on other plugins |
10–50 | Depends on collections or fields added by other plugins |
100+ | Must run last — audit, introspection, or final-config plugins |
Plugins often need to be aware of each other. The pattern for this is:
slug exposes its options object — the same object passed at call timeplugins map and mutates those options before the first plugin runsSince options are resolved before any plugin runs, this works cleanly without re-execution.
plugins mapEvery plugin created with definePlugin receives a plugins map — a slug-keyed object of all plugins in the config. No imports needed:
export const writerPlugin = definePlugin({
slug: 'my-writer',
order: 1,
plugin: ({ config, plugins }) => {
const seo = plugins['plugin-seo']
seo?.options?.collections.push('my-collection')
return config
},
})
For registered slugs (see below), the plugins map entries are automatically typed — no cast needed.
RegisteredPlugins — module augmentation for type safetyPlugin packages can register their slug and options type by augmenting the RegisteredPlugins interface. This ships with the package and is activated automatically when the plugin is imported — no code generation required.
// packages/plugin-seo/src/index.ts
export type SEOPluginOptions = {
collections: string[]
generateTitle?: (doc: Record<string, unknown>) => string
}
export const seoPlugin = definePlugin<SEOPluginOptions>({
slug: 'plugin-seo',
order: 10,
plugin: ({ config, collections }) => ({
...config,
// extend config here
}),
})
// Augment RegisteredPlugins — activated at import time, no generation step
declare module 'payload' {
interface RegisteredPlugins {
'plugin-seo': SEOPluginOptions
}
}
Once a plugin package augments RegisteredPlugins, any project that imports it gets typed access via the plugins map:
export const writerPlugin = definePlugin({
slug: 'my-writer',
order: 1,
plugin: ({ config, plugins }) => {
// plugins['plugin-seo'] is typed — options is SEOPluginOptions
plugins['plugin-seo']?.options?.collections.push('my-collection')
return config
},
})
Here is a complete example of two decoupled plugins that communicate via the plugins map. The writer plugin (order 1) runs first and injects an item into the reader plugin's options. The reader plugin (order 10) runs second and sees the injected item.
import type { Config } from 'payload'
import { definePlugin } from 'payload'
// --- reader plugin ---
export type ReaderPluginOptions = {
items: Array<{ name: string }>
}
export const readerPlugin = definePlugin<ReaderPluginOptions>({
slug: 'my-reader',
order: 10,
plugin: ({ config, items }) => ({
...config,
custom: {
...config.custom,
items: items.map((i) => i.name),
},
}),
})
declare module 'payload' {
interface RegisteredPlugins {
'my-reader': ReaderPluginOptions
}
}
// --- writer plugin (separate package) ---
export const writerPlugin = definePlugin({
slug: 'my-writer',
order: 1,
plugin: ({ config, plugins }) => {
// Runs before reader — mutates reader's options before reader executes
plugins['my-reader']?.options?.items.push({ name: 'injected-by-writer' })
return config
},
})
// --- payload.config.ts ---
plugins: [readerPlugin({ items: [{ name: 'user-provided' }] }), writerPlugin()]
// Result: reader sees ['user-provided', 'injected-by-writer']
Use cross-plugin mutation (plugins map + options mutation) when:
Use direct options when:
You can check whether a plugin is present without importing it:
const hasSeo = config.plugins?.some((p) => p.slug === 'plugin-seo') ?? false
Or via the plugins map inside a definePlugin function:
plugin: ({ config, plugins }) => {
if (plugins['plugin-seo']) {
// plugin-seo is installed — safe to mutate its options
}
return config
}