plugin-development

star 0

This skill should be used when the user asks to "build a Payload plugin", "create payload-plugin package", "write a Payload plugin from scratch", "add fields via plugin", "preserve hooks in plugin", "publish payload-plugin to npm", "plugin architecture in Payload", or needs to author or maintain a reusable PayloadCMS plugin.

Agents-Store By Agents-Store schedule Updated 5/17/2026

name: plugin-development description: This skill should be used when the user asks to "build a Payload plugin", "create payload-plugin package", "write a Payload plugin from scratch", "add fields via plugin", "preserve hooks in plugin", "publish payload-plugin to npm", "plugin architecture in Payload", or needs to author or maintain a reusable PayloadCMS plugin.

PayloadCMS — Plugin Development

A Payload plugin is a curried function that takes user options and returns a config transformer:

type Plugin = (incomingConfig: Config) => Config
type PluginFactory<O> = (options: O) => Plugin

export const myPlugin: PluginFactory<MyOptions> = (options) => (config) => {
  // mutate or return a new config
  return { ...config /* … */ }
}

User wires it up:

import { myPlugin } from 'payload-plugin-my-thing'

export default buildConfig({
  // …
  plugins: [
    myPlugin({ collections: ['posts'], featureFlag: true }),
  ],
})

Plugins run in order during config sanitization. They can:

  • Add or modify collections, globals, fields.
  • Inject hooks (always append, never replace).
  • Register endpoints, custom components, jobs, email handlers.
  • Register their own admin views.

Minimal Plugin

// src/plugin.ts
import type { Config } from 'payload'

export interface MyPluginConfig {
  /** Collections to enable plugin for */
  collections?: string[]
  /** Disable without uninstalling */
  disabled?: boolean
}

export const myPlugin =
  (options: MyPluginConfig) =>
  (config: Config): Config => {
    if (options.disabled) return config

    return {
      ...config,
      collections: config.collections?.map((collection) => {
        if (!options.collections?.includes(collection.slug)) return collection
        return {
          ...collection,
          fields: [
            ...(collection.fields ?? []),
            {
              name: 'pluginField',
              type: 'text',
              admin: { description: 'Added by my-plugin' },
            },
          ],
        }
      }),
    }
  }

Adding Hooks (Without Stomping)

Hooks are arrays. Spread the existing ones and append yours:

collections: config.collections?.map((collection) => ({
  ...collection,
  hooks: {
    ...collection.hooks,
    afterChange: [
      ...(collection.hooks?.afterChange ?? []),
      myAfterChangeHook,
    ],
    beforeDelete: [
      ...(collection.hooks?.beforeDelete ?? []),
      myBeforeDeleteHook,
    ],
  },
})),

Never replace the array — you'd destroy user hooks and other plugins' hooks. Same rule for access, endpoints, admin.components.

Adding Root Endpoints

return {
  ...config,
  endpoints: [
    ...(config.endpoints ?? []),
    {
      path: '/my-plugin/healthcheck',
      method: 'get',
      handler: (req) => Response.json({ ok: true }),
    },
  ],
}

Mounted at /api/my-plugin/healthcheck.

Adding Admin Components

Components are referenced by import-map path strings:

return {
  ...config,
  admin: {
    ...config.admin,
    components: {
      ...config.admin?.components,
      beforeDashboard: [
        ...(config.admin?.components?.beforeDashboard ?? []),
        'payload-plugin-my-thing/rsc#WelcomeBanner',
        'payload-plugin-my-thing/client#StatsClient',
      ],
    },
  },
}

Expose those components in your package's package.json exports:

{
  "exports": {
    ".":        { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
    "./rsc":    { "import": "./dist/rsc.js",   "types": "./dist/rsc.d.ts" },
    "./client": { "import": "./dist/client.js","types": "./dist/client.d.ts" },
    "./types":  { "types": "./dist/types.d.ts" }
  }
}

Adding Collections

return {
  ...config,
  collections: [
    ...(config.collections ?? []),
    {
      slug: 'my-plugin-records',
      admin: { group: 'My Plugin' },
      fields: [{ name: 'label', type: 'text' }],
    },
  ],
}

Always namespace your slugs (my-plugin-records) so they can't collide with user collections.

Adding Jobs / Tasks

return {
  ...config,
  jobs: {
    ...config.jobs,
    tasks: [
      ...(config.jobs?.tasks ?? []),
      myBackgroundTask,
    ],
  },
}

Package Structure (production-grade)

payload-plugin-my-thing/
├── src/
│   ├── index.ts                 # Main entry — exports plugin factory
│   ├── plugin.ts                # Implementation
│   ├── types.ts                 # PublicAPI types
│   ├── exports/
│   │   ├── client.ts            # 'use client' barrel
│   │   └── rsc.ts               # Server components barrel
│   ├── components/
│   │   ├── WelcomeBanner.tsx    # RSC
│   │   └── StatsClient.tsx      # 'use client'
│   ├── endpoints/
│   │   └── healthcheck.ts
│   ├── fields/
│   │   └── pluginField.ts
│   └── hooks/
│       └── afterChange.ts
├── dev/                          # A real Payload app for development
│   ├── payload.config.ts        # Imports plugin via 'workspace:*' or relative
│   ├── package.json
│   └── int.spec.ts              # Integration tests (Vitest)
├── .swcrc
├── tsconfig.json
├── vitest.config.ts
├── playwright.config.ts          # If you ship admin UI
├── package.json
└── README.md

package.json

{
  "name": "payload-plugin-my-thing",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".":        { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
    "./client": { "import": "./dist/exports/client.js", "types": "./dist/exports/client.d.ts" },
    "./rsc":    { "import": "./dist/exports/rsc.js",    "types": "./dist/exports/rsc.d.ts" }
  },
  "files": ["dist"],
  "scripts": {
    "build": "pnpm clean && pnpm build:types && pnpm build:swc",
    "build:swc": "swc ./src -d ./dist --strip-leading-paths",
    "build:types": "tsc --emitDeclarationOnly --outDir ./dist",
    "clean": "rm -rf dist",
    "dev": "cd dev && pnpm dev",
    "test:int": "vitest run",
    "test:e2e": "playwright test",
    "prepublishOnly": "pnpm clean && pnpm build"
  },
  "peerDependencies": {
    "payload": "^3.0.0"
  },
  "devDependencies": {
    "@swc/cli": "^0.5.0",
    "@swc/core": "^1.7.0",
    "payload": "^3.0.0",
    "typescript": "^5.5.0"
  }
}

.swcrc

{
  "$schema": "https://json.schemastore.org/swcrc",
  "jsc": {
    "target": "es2022",
    "parser": { "syntax": "typescript", "tsx": true, "decorators": true },
    "transform": { "react": { "runtime": "automatic" } }
  },
  "module": { "type": "es6" }
}

Why SWC + tsc?

  • SWC compiles fast and outputs runnable JS.
  • tsc --emitDeclarationOnly writes .d.ts files but skips JS emit.
  • Run both for a clean dist/ with both types and code.

Local Development with dev/

The dev/ folder is a fully functional Payload app that imports your plugin:

// dev/payload.config.ts
import { myPlugin } from '../src'
import { postgresAdapter } from '@payloadcms/db-postgres'

export default buildConfig({
  // …
  plugins: [myPlugin({ collections: ['posts'] })],
  db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI } }),
})

Run pnpm --filter dev dev to start a real admin UI against your local plugin code.

Testing

Integration (Vitest)

// dev/int.spec.ts
import { getPayload, type Payload } from 'payload'
import { describe, it, expect, beforeAll } from 'vitest'
import config from './payload.config'

let payload: Payload

beforeAll(async () => {
  payload = await getPayload({ config })
})

describe('plugin field is added', () => {
  it('exists on the posts collection', () => {
    const posts = payload.collections.posts.config.fields
    expect(posts.some((f) => 'name' in f && f.name === 'pluginField')).toBe(true)
  })

  it('persists data', async () => {
    const created = await payload.create({
      collection: 'posts',
      data: { title: 't', pluginField: 'v' },
    })
    expect(created.pluginField).toBe('v')
  })
})

E2E (Playwright)

If your plugin renders admin components, run Playwright against dev/:

pnpm --filter dev dev &
pnpm test:e2e

Options Patterns

Per-collection overrides

type CollectionOptions = boolean | {
  fields?: Field[]
  hooks?: CollectionConfig['hooks']
}

interface MyPluginConfig {
  collections?: Record<string, CollectionOptions>
}

// usage:
myPlugin({
  collections: {
    posts: true,
    pages: { fields: [/* extra fields */] },
  },
})

Disable without uninstall

if (options.disabled) {
  // Keep DB schema (don't strip fields) but skip side effects
  return config
}

This is the convention: never silently drop fields when disabled, otherwise users lose data.

Conditional behavior per environment

if (process.env.NODE_ENV === 'production') {
  // Only attach prod-only endpoints
}

Publishing

pnpm changeset                    # If using changesets
pnpm build
npm publish --access public

Suggest payload-plugin-<slug> naming so users can pnpm add payload-plugin-stripe.

Tag the GitHub release with the version, attach a CHANGELOG, link the plugin in the Payload community list (payloadcms/payload-plugins).

See Also

  • The hooks skill — patterns to wrap when injecting hooks.
  • The fields skill — field type guards for safely modifying user fields.
  • The nextjs-integration skill — components must be RSC- or client-explicit.
  • Real-world plugins to study: @payloadcms/plugin-seo, @payloadcms/plugin-nested-docs, @payloadcms/plugin-form-builder, @payloadcms/plugin-search, @payloadcms/plugin-stripe, @payloadcms/plugin-multi-tenant.
Install via CLI
npx skills add https://github.com/Agents-Store/claude-plugins --skill plugin-development
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
Agents-Store
Agents-Store Explore all skills →