Payload plugins are still simple at their core: receive a config, return a modified config.
What changed in modern Payload is not the contract, but the authoring surface. With definePlugin, you can build plugins that are easier to publish, easier to type, and easier to extend across packages.
This guide is the version I wish I had when I built a real plugin on top of Payload 3.85+ It covers:
the mental model behind Payload plugins
how definePlugin actually works
how to structure a real plugin package
how to split server and client exports cleanly
how to test and publish a plugin from zero
One caveat up front: Payload currently documents the advanced plugin API as experimental, even though it also recommends definePlugin for published plugins. So the approach in this article is the best current pattern, but you should expect some API surface to evolve over time.
The core plugin contract never changed
A Payload plugin is still just a function.
It takes a config, and it returns a config.
In its plain form, it looks like this:
import type { Config } from 'payload'
export const myPlugin =
(opts : MyPluginOptions ) =>
(config : Config ): Config => ({
...config,
collections : [...(config.collections || []), myCollection],
})
That pattern still works. Payload has been explicit that it is not going away.
So why use definePlugin at all?
Because once you are building a plugin that other projects will actually install, you usually want more than "just a function":
typed options
stable plugin identification
explicit execution ordering
optional cross-plugin interoperability
less boilerplate
That is what the advanced plugin API gives you.
What definePlugin actually adds
The modern version looks like this:
import { definePlugin } from 'payload'
export const myPlugin = definePlugin<MyPluginOptions >({
slug : 'my-plugin' ,
order : 10 ,
plugin : ({ config, plugins, ...options } ) => {
return {
...config,
}
},
})
The important thing to understand is this:
definePlugin does not invent a new plugin model. It wraps the same old one.
What you get from it is a factory function that:
accepts typed options
returns a Payload-compatible plugin
attaches metadata like slug, order, and options
gives the plugin callback access to a plugins map
That plugins map is where cross-plugin coordination comes from. If another plugin in the same config has a slug, you can discover it there and optionally mutate its options before it runs.
That is powerful, but it is not the main thing most plugins need. The main value is simpler, cleaner published plugin authoring.
A minimal canonical plugin
If you are starting from scratch, build the smallest working version first.
src/types.ts
export type ExamplePluginOptions = {
enabled ?: boolean
collectionSlug ?: string
}
src/index.ts
import { definePlugin, type CollectionConfig } from 'payload'
import type { ExamplePluginOptions } from './types'
const createCollection = (slug : string ): CollectionConfig => ({
slug,
fields : [
{
name : 'title' ,
type : 'text' ,
required : true ,
},
],
})
const resolveOptions = (
options : ExamplePluginOptions ,
): Required <ExamplePluginOptions > => ({
enabled : true ,
collectionSlug : 'example-items' ,
...options,
})
export const examplePlugin = definePlugin<ExamplePluginOptions >({
slug : 'example-plugin' ,
order : 10 ,
plugin : ({ config, ...pluginOptions } ) => {
const options = resolveOptions (pluginOptions)
if (!options.enabled ) {
return config
}
return {
...config,
collections : [...(config.collections || []), createCollection (options.collectionSlug )],
}
},
})
export type { ExamplePluginOptions } from './types'
declare module 'payload' {
interface RegisteredPlugins {
'example-plugin' : ExamplePluginOptions
}
}
That already gives you most of the structure a published plugin should have:
a typed options object
defaults in one place
a clean opt-out with enabled
safe config extension
a plugin slug
typed registration in RegisteredPlugins
The mental model that scales
Once the minimal version works, the next step is not "add more code everywhere."
The next step is separation.
A real plugin should usually split into a few clear layers:
public options and types
entrypoint and plugin factory
feature builders such as collections, globals, hooks, or endpoints
optional server utilities
optional client-only exports
That split matters because Payload plugins often sit at the boundary of several concerns:
config authoring
server behavior
admin UI
consumer app integration
package publishing
If you keep all of that in one file, the plugin becomes hard to reason about very quickly.
How I structured a real plugin
In my case, I built a feedback plugin for Payload admin and selected frontend routes. Its shape looks like this:
src/
index.ts
types.ts
collections/
AdminFeedback.ts
endpoints/
submit.ts
upload.ts
deleteUpload.ts
hooks/
beforeChange.ts
afterChange.ts
server/
sendFeedbackEmail.ts
tenant/
extractTenantSlug.ts
resolveUploadTenant.ts
client/
index.ts
FeedbackWidget.tsx
AdminFeedbackWidget.tsx
FrontendFeedbackWidget.tsx
scripts/
stamp-client-boundary.mjs
That is more than a minimal plugin, but the structure is still straightforward:
src/index.ts
owns plugin registration
resolves defaults
validates assumptions
appends the feature to config
src/types.ts
owns public plugin options
owns internal helper types
src/collections
owns the collection added by the plugin
src/endpoints
owns custom endpoints attached to that collection
src/hooks
src/server
owns isolated server utilities
src/client
owns client-facing React exports
That separation was not cosmetic. It made the plugin publishable.
The actual definePlugin shape in a real package
Here is the important pattern from a real plugin:
export const adminFeedbackPlugin = definePlugin<AdminFeedbackPluginOptions >({
slug : PLUGIN_SLUG ,
order : DEFAULT_ORDER ,
plugin : ({ config, ...pluginOptions } ) => {
const options = resolveOptions (pluginOptions)
if (!options.enabled ) {
return config
}
if (!options.emailTo || (Array .isArray (options.emailTo ) && options.emailTo .length === 0 )) {
throw new Error ('adminFeedbackPlugin requires emailTo option.' )
}
if (pluginOptions.email && !config.email ) {
config.email = pluginOptions.email
}
const resolvedMediaCollectionSlug = resolveMediaCollectionSlug (config, options)
const feedbackCollection = createAdminFeedbackCollection (options, resolvedMediaCollectionSlug)
return {
...config,
collections : [...(config.collections || []), feedbackCollection],
}
},
})
This is the shape I would recommend for most real plugins.
Why?
Because most plugins end up needing the same sequence:
accept user options
resolve defaults
fail early if required assumptions are missing
derive internal values
build one or more config fragments
append them safely to the incoming config
That is the canonical pattern.
Resolve defaults once, not everywhere
One of the easiest mistakes in plugin code is spreading conditionals all over the package.
If you let every hook, endpoint, and helper interpret partial user options on its own, the plugin becomes inconsistent and brittle.
A better approach is to create a single resolveOptions layer:
const resolveOptions = (
pluginOptions : AdminFeedbackPluginOptions ,
): ResolvedAdminFeedbackPluginOptions => ({
enabled : true ,
allowScreenshotUpload : true ,
maxMessageLength : 3000 ,
mediaCollectionSlug : 'media' ,
strictMediaCollection : true ,
...pluginOptions,
frontend : {
enabled : true ,
include : [],
...pluginOptions.frontend ,
},
screenshot : {
enabled : true ,
maxFileSizeBytes : 5 * 1024 * 1024 ,
allowedMimeTypes : ['image/png' , 'image/jpeg' , 'image/webp' ],
capturePolicy : 'current-tab-first' ,
...pluginOptions.screenshot ,
},
})
That gives you one normalized internal shape.
After that, the rest of the code can assume it is working with resolved values, not partial input.
This matters more than it looks. It is the difference between a plugin that stays maintainable and one that degenerates into "what does this option mean in this file?"
Validate assumptions at startup
The second major mistake is waiting too long to validate.
If your plugin requires one of these, validate it before Payload finishes building config:
a required option
an existing collection
an upload-enabled collection
an email adapter
a specific root config surface
In my plugin, the feedback feature depends on an upload-enabled media collection. So the plugin validates that immediately.
That is much better than "plugin installs fine, then explodes during the first screenshot upload request."
A good plugin should fail early and fail clearly.
Safe config mutation patterns
Payload config is compositional. Your plugin should behave the same way.
The safe defaults are simple:
preserve the incoming config with object spread
preserve arrays with array spread
append instead of replace
wrap existing function config if you need additive behavior
avoid destructive mutation unless there is a very good reason
Adding a collection should look like this:
return {
...config,
collections : [...(config.collections || []), myCollection],
}
If you need to extend function-valued config such as onInit, wrap it:
const incomingOnInit = config.onInit
return {
...config,
onInit : async (payload) => {
if (incomingOnInit) {
await incomingOnInit (payload)
}
await doPluginInitWork (payload)
},
}
That is the additive mindset you want throughout the plugin.
RegisteredPlugins is worth shipping
If you are publishing a plugin, do the module augmentation.
declare module 'payload' {
interface RegisteredPlugins {
'example-plugin' : ExamplePluginOptions
}
}
This is what gives other plugins typed access through the plugins map:
plugins['example-plugin' ]?.options
It does not change runtime behavior. It changes interoperability.
That matters if you are building packages meant to coexist with other Payload plugins.
When cross-plugin mutation is useful
Cross-plugin mutation is one of the more interesting parts of the advanced API, but it should be used sparingly.
It is useful when:
two plugins are decoupled packages
one plugin optionally enhances another
the user should not have to manually wire them together
Example:
import { definePlugin } from 'payload'
export const writerPlugin = definePlugin ({
slug : 'writer-plugin' ,
order : 1 ,
plugin : ({ config, plugins } ) => {
plugins['plugin-seo' ]?.options ?.collections .push ('extra-collection' )
return config
},
})
This works because plugin options are available before plugin execution, so one plugin can modify another plugin’s options before that target plugin runs.
But this is not the default pattern you should reach for.
If a user can just pass an option directly, that is usually clearer and better.
The server/client split matters more than it seems
A big part of building a real Payload plugin is deciding what belongs in the root entrypoint and what belongs in a client export.
In my plugin:
the plugin itself lives in the root entrypoint
React widgets live in a dedicated ./client subpath
That is important because plugin registration is server-side config code. You do not want browser-only dependencies leaking into that path.
The client entrypoint looks like this:
'use client'
export { FeedbackWidget } from './FeedbackWidget'
export { AdminFeedbackWidget } from './AdminFeedbackWidget'
export { FrontendFeedbackWidget } from './FrontendFeedbackWidget'
And the package exports it separately:
{
"exports" : {
"." : {
"types" : "./dist/index.d.ts" ,
"import" : "./dist/index.js" ,
"require" : "./dist/index.cjs"
} ,
"./client" : {
"types" : "./dist/client/index.d.ts" ,
"import" : "./dist/client/index.js" ,
"require" : "./dist/client/index.cjs"
}
}
}
That is the cleanest pattern I have found for plugins that need both config behavior and consumer-mounted UI.
Preserve the client boundary in the built package
One subtle packaging problem is that your bundler may not preserve 'use client' the way you expect.
In my package, I solved that with a tiny post-build script:
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join (dirname (fileURLToPath (import .meta .url )), '..' )
const directive = "'use client';"
for (const file of ['dist/client/index.js' , 'dist/client/index.cjs' ]) {
const path = join (root, file)
const source = readFileSync (path, 'utf8' )
if (source.startsWith (directive)) {
continue
}
writeFileSync (path, `${directive} \n${source} ` )
}
That is not specific to Payload. It is just part of building a package that ships React client code safely.
A publishable package shape
If you want people to install your plugin, package structure matters.
A good baseline package.json looks like this:
{
"name" : "@your-scope/payload-plugin-example" ,
"version" : "0.1.0" ,
"description" : "Example Payload plugin." ,
"license" : "MIT" ,
"type" : "module" ,
"main" : "./dist/index.js" ,
"types" : "./dist/index.d.ts" ,
"files" : [ "dist" , "README.md" , "CHANGELOG.md" ] ,
"exports" : {
"." : {
"types" : "./dist/index.d.ts" ,
"import" : "./dist/index.js" ,
"require" : "./dist/index.cjs"
} ,
"./client" : {
"types" : "./dist/client/index.d.ts" ,
"import" : "./dist/client/index.js" ,
"require" : "./dist/client/index.cjs"
}
} ,
"scripts" : {
"build" : "tsup && node scripts/stamp-client-boundary.mjs" ,
"dev" : "pnpm --dir dev dev" ,
"test" : "pnpm --dir dev test" ,
"prepublishOnly" : "pnpm build"
} ,
"peerDependencies" : {
"payload" : "^3.85.0" ,
"react" : "^19.0.0" ,
"react-dom" : "^19.0.0"
}
}
And the matching tsup.config.ts can look like this:
import { defineConfig } from 'tsup'
const external = ['payload' , 'react' , 'react-dom' , 'next' , 'next/navigation' ]
export default defineConfig ([
{
entry : { index : 'src/index.ts' },
outDir : 'dist' ,
format : ['esm' , 'cjs' ],
dts : true ,
clean : true ,
splitting : false ,
treeshake : true ,
sourcemap : true ,
external,
},
{
entry : { 'client/index' : 'src/client/index.ts' },
outDir : 'dist' ,
format : ['esm' , 'cjs' ],
dts : true ,
splitting : false ,
treeshake : true ,
sourcemap : true ,
external,
},
])
That gives you:
dual module output
type declarations
isolated client export
predictable packaging for consumers
How to start from zero
If you want the smoothest path, start with Payload’s plugin template.
npx create-payload-app@latest --template plugin
cd my-plugin
pnpm install
pnpm dev
That gives you the missing pieces most "architecture-only" articles skip:
a working package shell
a local dev app
a test harness
a CI-ready starting point
That matters because building a plugin is not just writing src/index.ts. It is also:
developing against a real Payload app
testing assumptions in isolation
validating package output before publishing
Add a dev app early
If your plugin does anything non-trivial, wire a local development app immediately.
For example:
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { examplePlugin } from '../src'
export default buildConfig ({
secret : process.env .PAYLOAD_SECRET || 'dev-secret' ,
db : mongooseAdapter ({
url : process.env .DATABASE_URI !,
}),
collections : [
{
slug : 'media' ,
upload : true ,
fields : [],
},
],
plugins : [
examplePlugin ({
enabled : true ,
collectionSlug : 'example-items' ,
}),
],
})
This becomes even more important if your plugin depends on:
upload collections
auth
email
admin overrides
localization
tenant-aware behavior
A dev app makes those assumptions concrete instead of hypothetical.
Test observable behavior, not implementation trivia
The most useful plugin tests are not "did this helper function return the right temporary value?"
They are "does the consumer get the correct final behavior?"
For a plugin shaped like mine, the core test cases are:
plugin adds the collection
plugin does nothing when enabled: false
plugin throws when required options are missing
plugin throws if the required media collection is missing
plugin accepts a custom mediaCollectionSlug
plugin attaches the expected endpoints and hooks
client entrypoint builds separately from the root plugin entrypoint
That is the level of behavior you want to pin down.
The implementation order I would recommend
If I were building this plugin again from zero, I would do it in this order:
Create option types.
Build the plugin entrypoint with definePlugin.
Add default resolution.
Add startup validation.
Add the collection.
Add hooks.
Add endpoints.
Add server utilities.
Add client widgets.
Split root and client exports.
Add the post-build client boundary check.
Add the dev app.
Add tests.
Publish only after installing the packed tarball into a fresh project.
That order reduces debugging. You always want a stable base before adding the next layer.
Common mistakes I would avoid
The same mistakes show up over and over.
Replacing config instead of extending it
Bad:
config.collections = [myCollection]
Good:
return {
...config,
collections : [...(config.collections || []), myCollection],
}
Delaying validation until runtime
If the plugin cannot function without a collection, option, adapter, or config surface, fail during initialization.
Mixing client code into the root plugin entrypoint
Keep the plugin entrypoint server-safe. Export browser-facing React code from ./client.
Treating cross-plugin mutation as the default API
It is a useful escape hatch for decoupled packages, not a replacement for documented options.
Letting partial options leak through the whole codebase
Resolve them once, then work with a normalized internal type.
Publishing checklist
Before you publish a Payload plugin, do this:
run the build
inspect dist/
verify generated .d.ts files exist
verify ./client exports resolve correctly
run tests
run pnpm pack
install the tarball into a fresh Payload project
verify the plugin registers cleanly
verify the plugin-added config actually works
publish with the intended npm access level
add the payload-plugin GitHub topic
tag the release
follow SemVer from the first public release onward
This part is boring, but it is the difference between "works on my machine" and an actual reusable plugin.
Final thoughts
The most useful thing to understand about Payload plugins is that the old model still matters.
definePlugin is not magic. It is a better way to author the same idea:
take config in
extend it safely
return config out
What makes a plugin good is not the helper itself. It is the discipline around it:
typed options
centralized defaults
early validation
clean config extension
clear package boundaries
separate client exports
proper testing and publishing flow
That is what turned my plugin from "a thing that works in one project" into "a package that can be installed somewhere else."
If you are building a real Payload plugin in 3.85+, that is the pattern I would recommend following.