---
title: "Build Publishable Payload Plugins with definePlugin"
slug: "build-payload-plugins-defineplugin"
published: "2026-05-30"
updated: "2026-06-03"
validated: "2026-06-03"
categories:
  - "Payload"
tags:
  - "Payload plugin"
  - "definePlugin"
  - "Payload 3.85"
  - "plugin authoring"
  - "typed options"
  - "client-server separation"
  - "RegisteredPlugins"
  - "safe config extension"
  - "plugin publishing checklist"
  - "tsup"
  - "dev app"
  - "plugin testing"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload@3.85+"
  - "typescript@5.x (inferred)"
  - "tsup@6.x (inferred)"
  - "pnpm@8+ (inferred)"
  - "react@18+ (inferred)"
status: "stable"
llm-purpose: "Payload plugin guide: Learn to author, test, and publish plugins for Payload 3.85+ using definePlugin. Includes typed options, safe config, client exports."
llm-prereqs:
  - "Access to payload"
  - "Access to tsup"
  - "Access to pnpm"
  - "Access to TypeScript"
  - "Access to React"
llm-outputs:
  - "Completed outcome: Payload plugin guide: Learn to author, test, and publish plugins for Payload 3.85+ using definePlugin. Includes typed options, safe config, client exports."
---

**Summary Triples**
- (Payload plugin, contract, is a function that takes a Config and returns a modified Config)
- (definePlugin, purpose, provides typed options, stable identification, and ordering for publishable plugins)
- (definePlugin, recommendedFor, published plugins on Payload 3.85+ (advanced API, may evolve))
- (Plugin package, shouldExpose, separate server and client entrypoints to avoid bundling server-only code into the browser)
- (Typed options, benefit, enable consumers to get compile-time validation and clear API for plugin config)
- (Safe config extension, requires, merging existing config carefully (preserve arrays, avoid overwriting consumer plugins))
- (Testing locally, approach, use a dev Payload app in a pnpm workspace and tsup build/watch to iterate on the plugin)
- (Publishing, checklist, build bundle, declare peerDependencies (payload, react, etc.), set package exports, include types, test in downstream app)
- (RegisteredPlugins typing, use, augment global types or provide plugin option types so consumers get proper TS inference)
- (Plugin boilerplate, canBeSimplifiedBy, definePlugin helper to reduce boilerplate and provide stable metadata)

### {GOAL}
Payload plugin guide: Learn to author, test, and publish plugins for Payload 3.85+ using definePlugin. Includes typed options, safe config, client exports.

### {PREREQS}
- Access to payload
- Access to tsup
- Access to pnpm
- Access to TypeScript
- Access to React

### {STEPS}
1. Create option types and interfaces
2. Build plugin entrypoint with definePlugin
3. Implement default resolution and validation
4. Add collection, endpoints, and hooks
5. Resolve defaults and normalize options
6. Validate required assumptions at startup
7. Append config safely using spreads
8. Split root and client exports
9. Add client widgets and isolated export
10. Create dev app and run tests
11. Build package and stamp client boundary
12. Publish only after verification and testing

<!-- llm:goal="Payload plugin guide: Learn to author, test, and publish plugins for Payload 3.85+ using definePlugin. Includes typed options, safe config, client exports." -->
<!-- llm:prereq="Access to payload" -->
<!-- llm:prereq="Access to tsup" -->
<!-- llm:prereq="Access to pnpm" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:output="Completed outcome: Payload plugin guide: Learn to author, test, and publish plugins for Payload 3.85+ using definePlugin. Includes typed options, safe config, client exports." -->

# Build Publishable Payload Plugins with definePlugin
> Payload plugin guide: Learn to author, test, and publish plugins for Payload 3.85+ using definePlugin. Includes typed options, safe config, client exports.
Matija Žiberna · 2026-05-30

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.

## LLM Response Snippet
```json
{
  "goal": "Payload plugin guide: Learn to author, test, and publish plugins for Payload 3.85+ using definePlugin. Includes typed options, safe config, client exports.",
  "responses": [
    {
      "question": "What does the article \"Build Publishable Payload Plugins with definePlugin\" cover?",
      "answer": "Payload plugin guide: Learn to author, test, and publish plugins for Payload 3.85+ using definePlugin. Includes typed options, safe config, client exports."
    }
  ]
}
```