Skip to content

Commit 54189e1

Browse files
authored
feat: expand plugin API (#16247)
## Why Payload's plugin system works but lacks built-in support for execution ordering, cross-plugin discovery, and typed options. Plugin authors manually attach metadata and consumers have no ergonomic way to find or interact with other plugins. This PR adds a small, opt-in API layer on top of the existing (config) => config contract. ## What ### definePlugin helper Replaces boilerplate for published plugins. The plugin function receives a single flat object with config, a plugins map, and user options spread in: ```ts import { definePlugin } from 'payload' export const seoPlugin = definePlugin<SEOPluginOptions>({ slug: 'plugin-seo', order: 10, plugin: ({ config, plugins, collections, generateTitle }) => ({ ...config, // collections and generateTitle come from SEOPluginOptions }), }) ``` ### Execution ordering via order Plugins are sorted by order before execution (lower runs first, default 0). Array position is used as a tiebreaker. ```ts plugins: [ analyticsPlugin(), // order 10 — runs second basePlugin(), // order 1 — runs first ] ``` ### Typed plugins map for cross-plugin communication Each definePlugin function receives a slug-keyed plugins map — no imports needed: ```ts plugin: ({ config, plugins }) => { const seo = plugins['plugin-seo'] seo?.options?.collections.push('my-collection') return config } ``` ### RegisteredPlugins module augmentation Plugin packages register their slug/options type for automatic type safety: ```ts declare module 'payload' { interface RegisteredPlugins { 'plugin-seo': SEOPluginOptions } } ``` ## What's unchanged The plain function form `(config) => ({ ...config })` still works and always will. Everything above is opt-in.
1 parent 1ef43eb commit 54189e1

File tree

7 files changed

+383
-35
lines changed

7 files changed

+383
-35
lines changed

docs/plugins/plugin-api.mdx

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
---
2+
title: Advanced Plugin API
3+
label: Advanced Plugin API
4+
order: 25
5+
desc: Use definePlugin and RegisteredPlugins to build powerful, interoperable plugins with execution ordering and typed cross-plugin communication.
6+
keywords: plugins, definePlugin, RegisteredPlugins, order, cross-plugin, configuration, extensions, typescript, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
7+
---
8+
9+
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.
10+
11+
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.
12+
13+
<Banner type="info">
14+
The API on this page is marked `@internal` while we finalize the design. It is
15+
safe to use across Payload's own plugins, but the surface may change before
16+
being declared stable.
17+
</Banner>
18+
19+
## The basics still work
20+
21+
The plain function form is unchanged and will always be supported:
22+
23+
```ts
24+
import type { Config } from 'payload'
25+
26+
export const myPlugin =
27+
(opts: MyOptions) =>
28+
(config: Config): Config => ({
29+
...config,
30+
collections: [...(config.collections || []), myCollection],
31+
})
32+
```
33+
34+
Everything below builds on top of this — none of it is required for simple plugins.
35+
36+
## `definePlugin` — recommended for published plugins
37+
38+
`definePlugin` 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:
39+
40+
```ts
41+
export const seoPlugin = definePlugin<SEOPluginOptions>({
42+
slug: 'plugin-seo',
43+
order: 10,
44+
plugin: ({ config, plugins, collections }) => ({
45+
...config,
46+
collections: [...(config.collections || []), seoCollection],
47+
}),
48+
})
49+
```
50+
51+
Import it from `payload`:
52+
53+
```ts
54+
import { definePlugin } from 'payload'
55+
```
56+
57+
The result of `definePlugin` is a factory function — call it with your options to get a `Plugin`:
58+
59+
```ts
60+
// payload.config.ts
61+
plugins: [seoPlugin({ collections: ['pages', 'posts'] })]
62+
```
63+
64+
## Execution ordering with `order`
65+
66+
By default, plugins execute in the order they appear in the `plugins` array. Setting `order` lets you declare execution order explicitly, regardless of array position.
67+
68+
**Lower order values run first.** The default is `0`.
69+
70+
```ts
71+
// This plugin runs first (order 1), even though it's listed second
72+
plugins: [
73+
analyticsPlugin({ trackingId: 'UA-...' }), // order 10 — runs second
74+
basePlugin(), // order 1 — runs first
75+
]
76+
```
77+
78+
### Suggested order conventions
79+
80+
Settle on a convention so the ecosystem converges:
81+
82+
| Range | Use case |
83+
| -------- | ------------------------------------------------------------- |
84+
| Negative | Must run before everything — config normalization, polyfills |
85+
| `0` | Default — no dependencies on other plugins |
86+
| `10–50` | Depends on collections or fields added by other plugins |
87+
| `100+` | Must run last — audit, introspection, or final-config plugins |
88+
89+
## Cross-plugin communication
90+
91+
Plugins often need to be aware of each other. The pattern for this is:
92+
93+
1. A plugin with a `slug` exposes its `options` object — the same object passed at call time
94+
2. Another plugin finds it via the `plugins` map and mutates those options before the first plugin runs
95+
3. When the first plugin executes, it sees the mutated options
96+
97+
Since options are resolved before any plugin runs, this works cleanly without re-execution.
98+
99+
### The `plugins` map
100+
101+
Every plugin created with `definePlugin` receives a `plugins` map — a slug-keyed object of all plugins in the config. No imports needed:
102+
103+
```ts
104+
export const writerPlugin = definePlugin({
105+
slug: 'my-writer',
106+
order: 1,
107+
plugin: ({ config, plugins }) => {
108+
const seo = plugins['plugin-seo']
109+
seo?.options?.collections.push('my-collection')
110+
return config
111+
},
112+
})
113+
```
114+
115+
For registered slugs (see below), the `plugins` map entries are automatically typed — no cast needed.
116+
117+
### `RegisteredPlugins` — module augmentation for type safety
118+
119+
Plugin 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.
120+
121+
```ts
122+
// packages/plugin-seo/src/index.ts
123+
export type SEOPluginOptions = {
124+
collections: string[]
125+
generateTitle?: (doc: Record<string, unknown>) => string
126+
}
127+
128+
export const seoPlugin = definePlugin<SEOPluginOptions>({
129+
slug: 'plugin-seo',
130+
order: 10,
131+
plugin: ({ config, collections }) => ({
132+
...config,
133+
// extend config here
134+
}),
135+
})
136+
137+
// Augment RegisteredPlugins — activated at import time, no generation step
138+
declare module 'payload' {
139+
interface RegisteredPlugins {
140+
'plugin-seo': SEOPluginOptions
141+
}
142+
}
143+
```
144+
145+
Once a plugin package augments `RegisteredPlugins`, any project that imports it gets typed access via the `plugins` map:
146+
147+
```ts
148+
export const writerPlugin = definePlugin({
149+
slug: 'my-writer',
150+
order: 1,
151+
plugin: ({ config, plugins }) => {
152+
// plugins['plugin-seo'] is typed — options is SEOPluginOptions
153+
plugins['plugin-seo']?.options?.collections.push('my-collection')
154+
return config
155+
},
156+
})
157+
```
158+
159+
## Full example: two interoperating plugins
160+
161+
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.
162+
163+
```ts
164+
import type { Config } from 'payload'
165+
import { definePlugin } from 'payload'
166+
167+
// --- reader plugin ---
168+
169+
export type ReaderPluginOptions = {
170+
items: Array<{ name: string }>
171+
}
172+
173+
export const readerPlugin = definePlugin<ReaderPluginOptions>({
174+
slug: 'my-reader',
175+
order: 10,
176+
plugin: ({ config, items }) => ({
177+
...config,
178+
custom: {
179+
...config.custom,
180+
items: items.map((i) => i.name),
181+
},
182+
}),
183+
})
184+
185+
declare module 'payload' {
186+
interface RegisteredPlugins {
187+
'my-reader': ReaderPluginOptions
188+
}
189+
}
190+
191+
// --- writer plugin (separate package) ---
192+
193+
export const writerPlugin = definePlugin({
194+
slug: 'my-writer',
195+
order: 1,
196+
plugin: ({ config, plugins }) => {
197+
// Runs before reader — mutates reader's options before reader executes
198+
plugins['my-reader']?.options?.items.push({ name: 'injected-by-writer' })
199+
return config
200+
},
201+
})
202+
203+
// --- payload.config.ts ---
204+
plugins: [readerPlugin({ items: [{ name: 'user-provided' }] }), writerPlugin()]
205+
// Result: reader sees ['user-provided', 'injected-by-writer']
206+
```
207+
208+
## When to use cross-plugin mutation vs. direct options
209+
210+
Use cross-plugin mutation (`plugins` map + options mutation) when:
211+
212+
- The two plugins are **decoupled packages** — one doesn't import the other
213+
- The extending plugin is **optional** — the target plugin should work without it
214+
- You want users to install both plugins independently without wiring them together manually
215+
216+
Use direct options when:
217+
218+
- The relationship is **intentional and documented** — the user is expected to pass options directly
219+
- The plugins are in the **same package** and share types already
220+
221+
## Checking if a plugin is installed
222+
223+
You can check whether a plugin is present without importing it:
224+
225+
```ts
226+
const hasSeo = config.plugins?.some((p) => p.slug === 'plugin-seo') ?? false
227+
```
228+
229+
Or via the `plugins` map inside a `definePlugin` function:
230+
231+
```ts
232+
plugin: ({ config, plugins }) => {
233+
if (plugins['plugin-seo']) {
234+
// plugin-seo is installed — safe to mutate its options
235+
}
236+
return config
237+
}
238+
```

packages/payload/src/config/build.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { sanitizeConfig } from './sanitize.js'
99
*/
1010
export async function buildConfig(config: Config): Promise<SanitizedConfig> {
1111
if (Array.isArray(config.plugins)) {
12-
const sorted = [...config.plugins].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
12+
const sorted = [...config.plugins].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
1313

1414
for (const plugin of sorted) {
1515
config = await plugin(config)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { Config, Plugin, PluginsMap } from './types.js'
2+
3+
function buildPluginsMap(plugins: Plugin[] | undefined): PluginsMap {
4+
const map: Record<string, Plugin | undefined> = {}
5+
if (plugins) {
6+
for (const p of plugins) {
7+
if (p.slug) {
8+
map[p.slug] = p
9+
}
10+
}
11+
}
12+
return map as PluginsMap
13+
}
14+
15+
/**
16+
* Helper for authoring plugins with order, slug, and typed options.
17+
* Eliminates boilerplate and ensures metadata is always set consistently.
18+
*
19+
* The `plugin` function receives a single object containing `config`, `plugins`
20+
* (a slug-keyed map of other plugins), and any user-provided options spread in.
21+
*
22+
* @example
23+
* // With options:
24+
* export const seoPlugin = definePlugin<SEOPluginOptions>({
25+
* slug: 'plugin-seo',
26+
* order: 10,
27+
* plugin: ({ config, plugins, collections }) => ({ ...config }),
28+
* })
29+
*
30+
* // Without options:
31+
* export const myPlugin = definePlugin({
32+
* slug: 'my-plugin',
33+
* plugin: ({ config }) => ({ ...config }),
34+
* })
35+
*/
36+
export function definePlugin(descriptor: {
37+
order?: number
38+
plugin: (args: { config: Config; plugins: PluginsMap }) => Config | Promise<Config>
39+
slug?: string
40+
}): () => Plugin
41+
export function definePlugin<TOptions extends Record<string, unknown>>(descriptor: {
42+
order?: number
43+
plugin: (args: { config: Config; plugins: PluginsMap } & TOptions) => Config | Promise<Config>
44+
slug?: string
45+
}): (options: TOptions) => Plugin
46+
export function definePlugin<TOptions extends Record<string, unknown>>(descriptor: {
47+
order?: number
48+
plugin: (args: { config: Config; plugins: PluginsMap } & TOptions) => Config | Promise<Config>
49+
slug?: string
50+
}): (options?: TOptions) => Plugin {
51+
return (options?: TOptions): Plugin => {
52+
const pluginFn: Plugin = (config) => {
53+
const plugins = buildPluginsMap(config.plugins)
54+
55+
const args = {
56+
...options,
57+
config,
58+
plugins,
59+
} as { config: Config; plugins: PluginsMap } & TOptions
60+
61+
return descriptor.plugin(args)
62+
}
63+
64+
pluginFn.options = options as Record<string, unknown>
65+
66+
if (descriptor.slug !== undefined) {
67+
pluginFn.slug = descriptor.slug
68+
}
69+
if (descriptor.order !== undefined) {
70+
pluginFn.order = descriptor.order
71+
}
72+
73+
return pluginFn
74+
}
75+
}

packages/payload/src/config/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import type {
5151
JobsConfig,
5252
KVAdapterResult,
5353
Payload,
54+
RegisteredPlugins,
5455
RequestContext,
5556
SelectField,
5657
TypedUser,
@@ -147,11 +148,19 @@ export type Plugin = ((config: Config) => Config | Promise<Config>) & {
147148
/** @internal Plugin options exposed for cross-plugin mutation. */
148149
options?: Record<string, unknown>
149150
/** @internal Execution order - lower values run first. Defaults to 0. */
150-
priority?: number
151+
order?: number
151152
/** @internal Unique identifier for cross-plugin discovery via `config.plugins`. */
152153
slug?: string
153154
}
154155

156+
/**
157+
* A map of plugin slugs to Plugin instances, built from `config.plugins`.
158+
* Registered slugs (via `RegisteredPlugins` module augmentation) return typed options.
159+
*/
160+
export type PluginsMap = {
161+
[K in keyof RegisteredPlugins]: ({ options: RegisteredPlugins[K] } & Plugin) | undefined
162+
} & Record<string, Plugin | undefined>
163+
155164
export type LivePreviewURLType = null | string | undefined
156165

157166
export type LivePreviewConfig = {

packages/payload/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,21 @@ export interface UntypedPayloadTypes {
266266
*/
267267
export interface GeneratedTypes {}
268268

269+
/**
270+
* Interface to be module-augmented by plugin packages.
271+
* Maps plugin slug to plugin options type, enabling typed cross-plugin
272+
* discovery via the `plugins` map passed to `definePlugin` functions.
273+
*
274+
* @example
275+
* // In a plugin package's index.ts:
276+
* declare module 'payload' {
277+
* interface RegisteredPlugins {
278+
* 'plugin-seo': SEOPluginOptions
279+
* }
280+
* }
281+
*/
282+
export interface RegisteredPlugins {}
283+
269284
/**
270285
* Check if GeneratedTypes has been augmented (has any keys).
271286
*/
@@ -1386,6 +1401,7 @@ export {
13861401
type UnauthenticatedClientConfig,
13871402
} from './config/client.js'
13881403
export { defaults } from './config/defaults.js'
1404+
export { definePlugin } from './config/definePlugin.js'
13891405

13901406
export { type OrderableEndpointBody } from './config/orderable/index.js'
13911407
export { sanitizeConfig } from './config/sanitize.js'

0 commit comments

Comments
 (0)