A practical guide to building, testing, and shipping Payload 3.x plugins from a real visual editor plugin.
Payload plugins look simple at first: take a config in, return a config out. That model is a gift. It means a plugin can add fields, endpoints, admin components, hooks, collections, and editor behavior from a small touch point.
The tricky part is not the plugin function itself. The tricky part is everything around it: local development, import maps, package exports, testing, HMR, and the difference between source code and the code your users install.
This article is based on lessons from building payload-visual-editor, a Payload plugin experiment for visual editing inside live previews. The repo follows the official Payload plugin template, but the most useful lessons came from the rough edges.
Start with the plugin shape
Payload plugins are config transformers. The common shape is a curried function: plugin options first, incoming Payload config second.
import type { Config, Plugin } from 'payload'
export const myPlugin =
(pluginOptions: MyPluginConfig): Plugin =>
(incomingConfig: Config): Config => {
const config: Config = {
...incomingConfig,
collections: [...(incomingConfig.collections || [])],
endpoints: [...(incomingConfig.endpoints || [])],
}
// Extend collections, fields, endpoints, hooks, or admin config.
if (pluginOptions.disabled) {
return config
}
return config
}
The important habit is to preserve what the app already has. Spread existing arrays. Keep existing hooks. Call an existing onInit before adding your own initialization. Treat the user app as the owner of the config.
That sounds obvious, but it is where many plugin bugs begin. A plugin should compose with the host app. It should not quietly replace parts of it.
Keep source, dev, and published output separate
In this repo, the plugin lives in three worlds.
payload-plugin-visual-editor/
|-- src/ # Plugin source
|-- dev/ # Full Payload + Next.js app for development
`-- dist/ # Compiled package output
Each world has a different job. src/ is the code you write. dev/ is a real Payload app that consumes the plugin while you build it. dist/ is the package your users install.
That separation keeps the feedback loop fast without lying about what you are shipping. During normal development, the dev app points at src/. Before publishing, the build proves that dist/ contains the files consumers will import.
Avoid a self-link dependency
One tempting local setup is to make the dev app install the plugin through a self-reference like link:.. It works locally, but it makes the package model muddy.
The cleaner approach is to let the dev app resolve the package name to source files. In the example repo, that takes three small pieces.
First, dev/tsconfig.json maps the package name and public subpaths to files under src/. Second, dev/next.config.mjs maps the same package subpaths at runtime for Next.js. Third, dev/payload.config.ts imports the plugin entry directly, because Payload’s CLI runs under Node and does not know about Next.js aliases.
import { payloadVisualEditor } from '../src/index.js'
export default buildConfig({
plugins: [payloadVisualEditor({ collections: { posts: true } })],
})
This gives you a fast local loop. You can edit TypeScript in src/, refresh the dev app, and avoid rebuilding the package after every change. Use pnpm build when you want to validate the published artifact.
Be explicit about public entry points
A Payload plugin usually has more than one kind of code. The plugin function is server-safe config code. Admin UI components may be client components. Some admin components can be React Server Components.
Those should not all be exported through one door. The package in this repo exposes separate entry points.
{
"exports": {
".": "./dist/index.js",
"./client": "./dist/exports/client.js",
"./rsc": "./dist/exports/rsc.js"
}
}
The main export is for the plugin function and shared types. The /client export is for components and hooks with 'use client'. The /rsc export is for server components used in admin slots that support them.
This makes your package easier to reason about. It also helps consumers avoid accidentally pulling client-only code into server config.
Admin components are resolved by path string
Payload admin components are not usually direct imports in your config. They are identified by component paths that Payload resolves while building the admin panel. The Payload custom components docs cover the model in detail.
In a plugin, the path often points to your package name and a named export.
collection.admin.components.edit.beforeDocumentControls = [
...(collection.admin.components.edit.beforeDocumentControls || []),
'payload-plugin-visual-editor/client#VisualEditorAdmin',
]
That string has to line up with your package exports.
// src/exports/client.ts
export { VisualEditorAdmin } from '../admin/VisualEditorAdmin.js'
If the string says payload-plugin-visual-editor/client#VisualEditorAdmin, then your dev aliases must also understand payload-plugin-visual-editor/client. The generated import map must understand it too. That is why import maps deserve their own workflow.
pnpm generate:types
pnpm generate:importmap
Regenerate the import map whenever you add, rename, remove, or move admin components. If an admin component stops rendering after a rename, suspect the import map first.
Configure Next.js for the dev app, not for the published package
The dev app in this repo uses @payloadcms/next/withPayload with a normal Next.js config. Because the dev app aliases the package name to src/, Next.js compiles those files as local source.
That means the dev app does not need transpilePackages for this plugin during local development. Consumers install the built package from dist/. For them, transpilePackages should be unnecessary unless you see a real parse error in a downstream Next.js app.
That distinction matters. Do not ask every consumer to carry workaround config just because your development setup needed aliases.
Cache in-memory MongoDB across HMR
The most surprising bug in this setup came from the database. For local development and tests, the repo can run with mongodb-memory-server instead of a persistent DATABASE_URL.
That is convenient, but there is a trap when Payload runs inside next dev. Next.js Hot Module Replacement can re-evaluate payload.config.ts. Turbopack can also reset module caches.
If the config creates a fresh in-memory MongoDB instance on every load, each HMR cycle gets a new empty database. Seeded content disappears. The frontend may show stale or empty state.
The fix is to cache the memory server URI on globalThis for the life of the Node process.
const globalStore = globalThis as unknown as {
_mongoMemoryDBUri?: string
}
if (!globalStore._mongoMemoryDBUri) {
const memoryDB = await MongoMemoryReplSet.create({
replSet: { count: 3, dbName: 'payloadmemory' },
})
globalStore._mongoMemoryDBUri = `${memoryDB.getUri()}&retryWrites=true`
}
process.env.DATABASE_URL = globalStore._mongoMemoryDBUri
Use MongoMemoryReplSet, not a standalone memory server, when testing transactional behavior. MongoDB transactions require a replica set. Use a real DATABASE_URL in dev/.env when you want data to survive full process restarts.
Test at the right layer
Good plugin tests are layered. Use Vitest unit tests for pure logic in src/. Those tests should be fast and free of Payload bootstrapping when possible.
Use integration tests when you need a real Payload instance. That includes endpoints, schema transformations, hooks, and Local API behavior.
beforeAll(async () => {
payload = await getPayload({ config })
})
afterAll(async () => {
await payload.db.destroy()
})
Always tear down the database in afterAll. That is especially important with in-memory MongoDB and parallel test workers.
Use Playwright for behavior that depends on real browser APIs. Selection, contenteditable, live preview iframes, drag-and-drop, and focus handling are all browser problems. Do not pretend they are unit-test problems.
For this plugin, E2E tests run serially because the dev app shares an in-memory database. That tradeoff is worth it. It avoids races while testing the behavior users actually touch.
The live preview lesson
The most interesting part of this plugin is not the package setup. It is the live preview editing model.
Visual editing means touching real DOM inside a preview while Payload is also pushing document updates. That creates a sharp edge. If the user is typing inside a contenteditable region and the preview re-renders, React can wipe the DOM node.
When that happens, the cursor jumps, selection disappears, and uncommitted text can be lost. The fix is to pause preview updates while edit mode is active. Queue the latest incoming preview data, then flush it when edit mode ends.
const pendingRef = useRef<DevPost | null>(null)
const editingRef = useRef(isVisualEditing)
editingRef.current = isVisualEditing
subscribe({
callback: (data) => {
if (editingRef.current) {
pendingRef.current = data
return
}
setPost(data)
},
})
useEffect(() => {
if (!isVisualEditing && pendingRef.current) {
setPost(pendingRef.current)
pendingRef.current = null
}
}, [isVisualEditing])
That pattern belongs in plugin documentation. The plugin can provide tools and conventions, but the consumer preview page still controls how live data enters React state.
Prefer field-scoped editable regions
There is another DOM lesson hiding in visual editing. Not every editable boundary feels natural.
Wrapping individual words in spans breaks native text selection. Making the entire page editable lets users delete boundaries between fields. The better compromise is to mark field-level regions.
Each editable region maps to one Payload field path. Inside that region, the browser can handle selection naturally. Outside it, the document structure remains protected.
For rich text, choose the largest valid wrapping block. Early versions matched the smallest leaf node, so each paragraph became its own editable island. That made drag selection across paragraphs awkward. It also made Cmd+A feel wrong.
Choosing the largest valid field wrapper made the rich text field behave like one editing surface. That kind of detail is easy to miss in an API design. It is painfully obvious when you test in a real browser.
Build before you publish
The publishing flow should prove that the package works as a package.
pnpm clean
pnpm build
pnpm test
pnpm check
The build should emit declarations and compiled JavaScript into dist/. The package should publish dist/, not src/ or the dev app.
Check peerDependencies before publishing. Payload belongs in peerDependencies, because the host app owns its Payload version. Your dev app can keep @payloadcms/ui, database adapters, Next.js, and test tooling in devDependencies.
Before opening a PR, I like to check this list.
- pnpm build succeeds.
- dist/ contains the files from the package exports map.
- Types are regenerated when schema changes.
- The import map is regenerated when admin components change.
- Unit, integration, and E2E tests pass.
- Typecheck and lint pass.
- The README explains install, config, exports, and any required consumer setup.
- Breaking changes are called out.
- The Payload peer dependency range is intentional.
Closing thought
Payload plugins are pleasant because the core abstraction is small. Most real-world complexity comes from making that small abstraction behave well inside a full app, a dev server, a package manager, and a browser.
The best plugin setup keeps those boundaries visible. Source code is not the package. The dev app is not the consumer app. Admin component paths are not regular imports. Live preview state is not normal page state.
Once those boundaries are clear, the work gets much calmer. You can focus on the plugin behavior instead of wrestling the toolchain.

Top comments (0)