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.
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.
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
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.
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."
Before you start authoring, it's worth knowing what's already out there — see the best Payload CMS plugins to check whether your idea already exists as a maintained community package.
If you are building a real Payload plugin in 3.85+, that is the pattern I would recommend following.