BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Build Publishable Payload Plugins with definePlugin

Build Publishable Payload Plugins with definePlugin

Practical guide to authoring, testing, and publishing Payload 3.85+ plugins using definePlugin, typed options, and…

30th May 2026·Updated on:3rd June 2026··
Payload
Build Publishable Payload Plugins with definePlugin

Need Help Making the Switch?

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.

Book Hourly Advisory

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The core plugin contract never changed
  • What `definePlugin` actually adds
  • A minimal canonical plugin
  • `src/types.ts`
  • `src/index.ts`
  • The mental model that scales
  • How I structured a real plugin
  • The actual `definePlugin` shape in a real package
  • Resolve defaults once, not everywhere
  • Validate assumptions at startup
  • Safe config mutation patterns
  • `RegisteredPlugins` is worth shipping
  • When cross-plugin mutation is useful
  • The server/client split matters more than it seems
  • Preserve the client boundary in the built package
  • A publishable package shape
  • How to start from zero
  • Add a dev app early
  • Test observable behavior, not implementation trivia
  • The implementation order I would recommend
  • Common mistakes I would avoid
  • Replacing config instead of extending it
  • Delaying validation until runtime
  • Mixing client code into the root plugin entrypoint
  • Treating cross-plugin mutation as the default API
  • Letting partial options leak through the whole codebase
  • Publishing checklist
  • Final thoughts
On this page:
  • The core plugin contract never changed
  • What `definePlugin` actually adds
  • A minimal canonical plugin
  • The mental model that scales
  • How I structured a real plugin
Build with Matija logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit

Resources

  • Case Studies
  • How I Work
  • Blog
  • CMS Hub
  • E-commerce Hub
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • Multi-Tenant CMS
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Get in Touch

Ready to modernize your stack? Let's talk about what you're building.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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:

ts
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:

ts
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

ts
export type ExamplePluginOptions = {
  enabled?: boolean
  collectionSlug?: string
}

src/index.ts

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:

txt
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
    • owns lifecycle behavior
  • 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:

ts
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:

  1. accept user options
  2. resolve defaults
  3. fail early if required assumptions are missing
  4. derive internal values
  5. build one or more config fragments
  6. 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:

ts
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:

ts
return {
  ...config,
  collections: [...(config.collections || []), myCollection],
}

If you need to extend function-valued config such as onInit, wrap it:

ts
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.

ts
declare module 'payload' {
  interface RegisteredPlugins {
    'example-plugin': ExamplePluginOptions
  }
}

This is what gives other plugins typed access through the plugins map:

ts
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:

ts
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:

ts
'use client'

export { FeedbackWidget } from './FeedbackWidget'
export { AdminFeedbackWidget } from './AdminFeedbackWidget'
export { FrontendFeedbackWidget } from './FrontendFeedbackWidget'

And the package exports it separately:

json
{
  "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:

js
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:

json
{
  "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:

ts
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.

bash
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:

ts
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:

  1. Create option types.
  2. Build the plugin entrypoint with definePlugin.
  3. Add default resolution.
  4. Add startup validation.
  5. Add the collection.
  6. Add hooks.
  7. Add endpoints.
  8. Add server utilities.
  9. Add client widgets.
  10. Split root and client exports.
  11. Add the post-build client boundary check.
  12. Add the dev app.
  13. Add tests.
  14. 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:

ts
config.collections = [myCollection]

Good:

ts
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.