Files
convex-monorepo-payload/.claude/skills/payload/reference/PLUGIN-DEVELOPMENT.md
2026-03-27 16:43:22 -05:00

34 KiB

Payload Plugin Development

Complete guide to creating Payload CMS plugins with TypeScript patterns, package structure, and best practices from the official Payload plugin template.

Plugin Architecture

Plugins are functions that receive configuration options and return a function that transforms the Payload config:

import type { Config, Plugin } from 'payload'

interface MyPluginConfig {
  enabled?: boolean
  collections?: string[]
}

export const myPlugin =
  (options: MyPluginConfig): Plugin =>
  (config: Config): Config => ({
    ...config,
    // Transform config here
  })

Key Pattern: Double arrow function (currying)

  • First function: Accepts plugin options, returns plugin function
  • Second function: Accepts Payload config, returns modified config

Plugin Package Structure

Simple Structure

plugin-<name>/
├── package.json              # Package metadata and dependencies
├── README.md                 # Plugin documentation
├── LICENSE.md                # License file
└── src/
    ├── index.ts              # Entry point, re-exports plugin and config types
    ├── plugin.ts             # Plugin implementation
    ├── types.ts              # TypeScript type definitions
    └── exports/              # Additional entry points (optional)
        └── types.ts          # Type-only exports

Exhaustive Structure

plugin-<name>/
├── .swcrc                    # SWC compiler config
├── package.json              # Package metadata and dependencies
├── tsconfig.json             # TypeScript config
├── README.md                 # Plugin documentation
├── LICENSE.md                # License file
├── eslint.config.js          # ESLint configuration (optional)
├── vitest.config.js          # Vitest test configuration (optional)
├── playwright.config.js      # Playwright e2e tests (optional)
└── src/
    ├── index.ts              # Entry point, re-exports plugin and config types
    ├── plugin.ts             # Plugin implementation
    ├── types.ts              # TypeScript type definitions
    ├── defaults.ts           # Default configuration values (optional)
    ├── endpoints/            # Custom API endpoints (optional)
    │   └── handler.ts
    ├── components/           # React components (optional)
    │   ├── ClientComponent.tsx    # 'use client' components
    │   └── ServerComponent.tsx    # RSC components
    ├── fields/               # Custom field components (optional)
    │   ├── FieldName/
    │   │   ├── index.ts      # Field config
    │   │   └── Component.tsx # Client component
    ├── exports/              # Additional entry points
    │   ├── types.ts          # Type-only exports
    │   ├── fields.ts         # Field-only exports
    │   ├── client.ts         # Re-export client components
    │   └── rsc.ts            # Re-export server components (RSC)
    ├── translations/         # i18n translations (optional)
    │   └── index.ts
    └── ui/                   # Admin UI components (optional)
        └── Component.tsx

Key additions from official template:

  • dev/ directory with complete Payload project for local testing
  • src/exports/rsc.ts for React Server Component exports
  • src/components/ for organizing React components
  • src/endpoints/ for custom API endpoint handlers
  • Test configuration files (vitest.config.js, playwright.config.js)

Package.json Configuration

{
  "name": "payload-plugin-example",
  "version": "1.0.0",
  "description": "A Payload CMS plugin",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./types": {
      "import": "./dist/exports/types.js",
      "types": "./dist/exports/types.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": "npm run copyfiles && npm run build:types && npm run build:swc",
    "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
    "build:types": "tsc --emitDeclarationOnly --outDir dist",
    "clean": "rimraf dist *.tsbuildinfo",
    "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
    "dev": "next dev dev --turbo",
    "dev:generate-types": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload generate:types",
    "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
    "test": "npm run test:int && npm run test:e2e",
    "test:int": "vitest",
    "test:e2e": "playwright test",
    "lint": "eslint",
    "lint:fix": "eslint ./src --fix",
    "prepublishOnly": "npm run clean && npm run build"
  },
  "dependencies": {
    "@payloadcms/translations": "^3.0.0",
    "@payloadcms/ui": "^3.0.0"
  },
  "devDependencies": {
    "@payloadcms/db-mongodb": "^3.0.0",
    "@payloadcms/next": "^3.0.0",
    "@payloadcms/richtext-lexical": "^3.0.0",
    "@playwright/test": "^1.40.0",
    "@swc/cli": "^0.1.62",
    "@swc/core": "^1.3.0",
    "copyfiles": "^2.4.1",
    "cross-env": "^7.0.3",
    "eslint": "^9.0.0",
    "next": "^15.4.10",
    "payload": "^3.0.0",
    "react": "^19.2.1",
    "react-dom": "^19.2.1",
    "rimraf": "^5.0.0",
    "typescript": "^5.0.0",
    "vitest": "^3.0.0"
  },
  "peerDependencies": {
    "payload": "^3.0.0"
  }
}

Key Points:

  • type: "module" for ESM
  • Compiled output in ./dist, source in ./src
  • Payload as peer dependency (user installs it)
  • Multiple export entry points: main, /types, /client, /rsc
  • /client for client components, /rsc for React Server Components
  • SWC for fast compilation
  • Dev scripts for local development with Next.js
  • Test scripts for both integration (Vitest) and e2e (Playwright) tests
  • prepublishOnly ensures build before publish

Plugin Patterns

Adding Fields to Collections

import type { Config, Plugin, Field } from 'payload'

export const seoPlugin =
  (options: { collections?: string[] }): Plugin =>
  (config: Config): Config => {
    const seoFields: Field[] = [
      {
        name: 'meta',
        type: 'group',
        fields: [
          { name: 'title', type: 'text' },
          { name: 'description', type: 'textarea' },
        ],
      },
    ]

    return {
      ...config,
      collections: config.collections?.map((collection) => {
        if (options.collections?.includes(collection.slug)) {
          return {
            ...collection,
            fields: [...(collection.fields || []), ...seoFields],
          }
        }
        return collection
      }),
    }
  }

Adding New Collections

import type { Config, Plugin, CollectionConfig } from 'payload'

export const redirectsPlugin =
  (options: { overrides?: Partial<CollectionConfig> }): Plugin =>
  (config: Config): Config => {
    const redirectsCollection: CollectionConfig = {
      slug: 'redirects',
      access: { read: () => true },
      fields: [
        { name: 'from', type: 'text', required: true, unique: true },
        { name: 'to', type: 'text', required: true },
      ],
      ...options.overrides,
    }

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

Adding Hooks

import type { Config, Plugin, CollectionAfterChangeHook } from 'payload'

const resaveChildrenHook: CollectionAfterChangeHook = async ({ doc, req, operation }) => {
  if (operation === 'update') {
    // Resave child documents
    const children = await req.payload.find({
      collection: 'pages',
      where: { parent: { equals: doc.id } },
    })

    for (const child of children.docs) {
      await req.payload.update({
        collection: 'pages',
        id: child.id,
        data: child,
      })
    }
  }
  return doc
}

export const nestedDocsPlugin =
  (options: { collections: string[] }): Plugin =>
  (config: Config): Config => ({
    ...config,
    collections: (config.collections || []).map((collection) => {
      if (options.collections.includes(collection.slug)) {
        return {
          ...collection,
          hooks: {
            ...(collection.hooks || {}),
            afterChange: [resaveChildrenHook, ...(collection.hooks?.afterChange || [])],
          },
        }
      }
      return collection
    }),
  })

Adding Root-Level Endpoints

Add endpoints at the root config level (accessible at /api/<path>):

import type { Config, Plugin, Endpoint } from 'payload'

export const seoPlugin =
  (options: { generateTitle?: (doc: any) => string }): Plugin =>
  (config: Config): Config => {
    const generateTitleEndpoint: Endpoint = {
      path: '/plugin-seo/generate-title',
      method: 'post',
      handler: async (req) => {
        const data = await req.json?.()
        const result = options.generateTitle ? options.generateTitle(data.doc) : ''
        return Response.json({ result })
      },
    }

    return {
      ...config,
      endpoints: [...(config.endpoints ?? []), generateTitleEndpoint],
    }
  }

Example webhook endpoint:

// Useful for integrations like Stripe
const webhookEndpoint: Endpoint = {
  path: '/stripe/webhook',
  method: 'post',
  handler: async (req) => {
    const signature = req.headers.get('stripe-signature')
    const event = stripe.webhooks.constructEvent(
      await req.text(),
      signature,
      process.env.STRIPE_WEBHOOK_SECRET,
    )
    // Handle webhook
    return Response.json({ received: true })
  },
}

Field Overrides with Defaults

import type { Config, Plugin, Field } from 'payload'

type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]

interface PluginConfig {
  collections?: string[]
  fields?: FieldsOverride
}

export const myPlugin =
  (options: PluginConfig): Plugin =>
  (config: Config): Config => {
    const defaultFields: Field[] = [
      { name: 'title', type: 'text' },
      { name: 'description', type: 'textarea' },
    ]

    const fields =
      options.fields && typeof options.fields === 'function'
        ? options.fields({ defaultFields })
        : defaultFields

    return {
      ...config,
      collections: config.collections?.map((collection) => {
        if (options.collections?.includes(collection.slug)) {
          return {
            ...collection,
            fields: [...(collection.fields || []), ...fields],
          }
        }
        return collection
      }),
    }
  }

Tabs UI Pattern

import type { Config, Plugin, TabsField, GroupField } from 'payload'

export const seoPlugin =
  (options: { tabbedUI?: boolean }): Plugin =>
  (config: Config): Config => {
    const seoFields: GroupField[] = [
      {
        name: 'meta',
        type: 'group',
        fields: [{ name: 'title', type: 'text' }],
      },
    ]

    return {
      ...config,
      collections: config.collections?.map((collection) => {
        if (options.tabbedUI) {
          const seoTabs: TabsField[] = [
            {
              type: 'tabs',
              tabs: [
                // If existing tabs, preserve them
                ...(collection.fields?.[0]?.type === 'tabs'
                  ? collection.fields[0].tabs
                  : [
                      {
                        label: 'Content',
                        fields: collection.fields || [],
                      },
                    ]),
                // Add SEO tab
                {
                  label: 'SEO',
                  fields: seoFields,
                },
              ],
            },
          ]

          return {
            ...collection,
            fields: [
              ...seoTabs,
              ...(collection.fields?.[0]?.type === 'tabs' ? collection.fields.slice(1) : []),
            ],
          }
        }

        return {
          ...collection,
          fields: [...(collection.fields || []), ...seoFields],
        }
      }),
    }
  }

Disable Plugin Pattern

Allow users to disable plugin without removing it (important for database schema consistency):

import type { Config, Plugin } from 'payload'

interface PluginConfig {
  disabled?: boolean
  collections?: string[]
}

export const myPlugin =
  (options: PluginConfig): Plugin =>
  (config: Config): Config => {
    // Always add collections/fields for database schema consistency
    if (!config.collections) {
      config.collections = []
    }

    config.collections.push({
      slug: 'plugin-collection',
      fields: [{ name: 'title', type: 'text' }],
    })

    // Add fields to specified collections
    if (options.collections) {
      for (const collectionSlug of options.collections) {
        const collection = config.collections.find((c) => c.slug === collectionSlug)
        if (collection) {
          collection.fields.push({
            name: 'addedByPlugin',
            type: 'text',
          })
        }
      }
    }

    // If disabled, return early but keep schema changes
    if (options.disabled) {
      return config
    }

    // Add endpoints, hooks, components only when enabled
    config.endpoints = [
      ...(config.endpoints ?? []),
      {
        path: '/my-endpoint',
        method: 'get',
        handler: async () => Response.json({ message: 'Hello' }),
      },
    ]

    return config
  }

Admin Components

Add custom UI components to the admin panel:

import type { Config, Plugin } from 'payload'

export const myPlugin =
  (options: PluginConfig): Plugin =>
  (config: Config): Config => {
    if (!config.admin) config.admin = {}
    if (!config.admin.components) config.admin.components = {}
    if (!config.admin.components.beforeDashboard) {
      config.admin.components.beforeDashboard = []
    }

    // Add client component
    config.admin.components.beforeDashboard.push('my-plugin-name/client#BeforeDashboardClient')

    // Add server component (RSC)
    config.admin.components.beforeDashboard.push('my-plugin-name/rsc#BeforeDashboardServer')

    return config
  }

Component file structure:

// src/components/BeforeDashboardClient.tsx
'use client'
import { useConfig } from '@payloadcms/ui'
import { useEffect, useState } from 'react'
import { formatAdminURL } from 'payload/shared'

export const BeforeDashboardClient = () => {
  const { config } = useConfig()
  const [data, setData] = useState('')

  useEffect(() => {
    fetch(
      formatAdminURL({
        apiRoute: config.routes.api,
        path: '/my-endpoint',
      }),
    )
      .then((res) => res.json())
      .then(setData)
  }, [config.serverURL, config.routes.api])

  return <div>Client Component: {data}</div>
}

// src/components/BeforeDashboardServer.tsx
export const BeforeDashboardServer = () => {
  return <div>Server Component</div>
}

// src/exports/client.ts
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'

// src/exports/rsc.ts
export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js'

Translations (i18n)

// src/translations/index.ts
export const translations = {
  en: {
    'plugin-name:fieldLabel': 'Field Label',
    'plugin-name:fieldDescription': 'Field description',
  },
  es: {
    'plugin-name:fieldLabel': 'Etiqueta del campo',
    'plugin-name:fieldDescription': 'Descripción del campo',
  },
}

// src/plugin.ts
import { deepMergeSimple } from 'payload/shared'
import { translations } from './translations/index.js'

export const myPlugin =
  (options: PluginConfig): Plugin =>
  (config: Config): Config => ({
    ...config,
    i18n: {
      ...config.i18n,
      translations: deepMergeSimple(translations, config.i18n?.translations ?? {}),
    },
  })

onInit Hook

export const myPlugin =
  (options: PluginConfig): Plugin =>
  (config: Config): Config => {
    const incomingOnInit = config.onInit

    config.onInit = async (payload) => {
      // IMPORTANT: Call existing onInit first
      if (incomingOnInit) await incomingOnInit(payload)

      // Plugin initialization
      payload.logger.info('Plugin initialized')

      // Example: Seed data
      const { totalDocs } = await payload.count({
        collection: 'plugin-collection',
        where: { id: { equals: 'seeded-by-plugin' } },
      })

      if (totalDocs === 0) {
        await payload.create({
          collection: 'plugin-collection',
          data: { id: 'seeded-by-plugin' },
        })
      }
    }

    return config
  }

TypeScript Patterns

Plugin Config Types

import type { CollectionSlug, GlobalSlug, Field, CollectionConfig } from 'payload'

export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]

export interface MyPluginConfig {
  /**
   * Collections to enable this plugin for
   */
  collections?: CollectionSlug[]
  /**
   * Globals to enable this plugin for
   */
  globals?: GlobalSlug[]
  /**
   * Override default fields
   */
  fields?: FieldsOverride
  /**
   * Enable tabbed UI
   */
  tabbedUI?: boolean
  /**
   * Override collection config
   */
  overrides?: Partial<CollectionConfig>
}

Export Types

// src/exports/types.ts
export type { MyPluginConfig, FieldsOverride } from '../types.js'

// Usage
import type { MyPluginConfig } from '@payloadcms/plugin-example/types'

Client Components

Custom Field Component

// src/fields/CustomField/Component.tsx
'use client'
import { useField } from '@payloadcms/ui'
import type { TextFieldClientComponent } from 'payload'

export const CustomFieldComponent: TextFieldClientComponent = ({ field, path }) => {
  const { value, setValue } = useField<string>({ path })

  return (
    <div>
      <label>{field.label}</label>
      <input value={value || ''} onChange={(e) => setValue(e.target.value)} />
    </div>
  )
}
// src/fields/CustomField/index.ts
import type { Field } from 'payload'

export const CustomField = (overrides?: Partial<Field>): Field => ({
  name: 'customField',
  type: 'text',
  admin: {
    components: {
      Field: '/fields/CustomField/Component#CustomFieldComponent',
    },
  },
  ...overrides,
})

Best Practices

Preserve Existing Config

Always spread existing config and add to arrays:

// ✅ Good
collections: [...(config.collections || []), newCollection]

// ❌ Bad
collections: [newCollection]

Respect User Overrides

Allow users to override plugin defaults:

const collection: CollectionConfig = {
  slug: 'redirects',
  fields: defaultFields,
  ...options.overrides, // User overrides last
}

Conditional Logic

Check if collections/globals are enabled:

collections: config.collections?.map((collection) => {
  const isEnabled = options.collections?.includes(collection.slug)
  if (isEnabled) {
    // Transform collection
  }
  return collection
})

Hook Composition

Preserve existing hooks:

hooks: {
  ...collection.hooks,
  afterChange: [
    myHook,
    ...(collection.hooks?.afterChange || []),
  ],
}

Type Safety

Use Payload's exported types:

import type { Config, Plugin, CollectionConfig, Field, CollectionSlug, GlobalSlug } from 'payload'

Field Path Imports

Use absolute paths for client components:

admin: {
  components: {
    Field: '/fields/CustomField/Component#CustomFieldComponent',
  },
}

onInit Pattern

Always call existing onInit before your initialization. See onInit Hook pattern for full example.

Advanced Patterns

These patterns are extracted from official Payload plugins and represent production-ready techniques for complex plugin development.

Advanced Configuration

Async Plugin Function

Allow plugin function to be async for awaiting collection overrides or async operations:

export const myPlugin =
  (pluginConfig?: PluginConfig) =>
  async (incomingConfig: Config): Promise<Config> => {
    // Can await async operations during initialization
    const customCollection = await pluginConfig.collectionOverride?.({
      defaultCollection,
    })

    return {
      ...incomingConfig,
      collections: [...incomingConfig.collections, customCollection],
    }
  }

Collection Override with Async Support

Allow users to override entire collections with async functions:

type CollectionOverride = (args: {
  defaultCollection: CollectionConfig
}) => CollectionConfig | Promise<CollectionConfig>

interface PluginConfig {
  products?: {
    collectionOverride?: CollectionOverride
  }
}

// In plugin
const defaultCollection = createProductsCollection(config)
const finalCollection = config.products?.collectionOverride
  ? await config.products.collectionOverride({ defaultCollection })
  : defaultCollection

Config Sanitization Pattern

Normalize plugin configuration with defaults:

export const sanitizePluginConfig = ({ pluginConfig }: Props): SanitizedPluginConfig => {
  const config = { ...pluginConfig } as Partial<SanitizedPluginConfig>

  // Normalize boolean|object configs
  if (typeof config.addresses === 'undefined' || config.addresses === true) {
    config.addresses = { addressFields: defaultAddressFields() }
  } else if (config.addresses === false) {
    config.addresses = null
  }

  // Validate required fields
  if (!config.stripeSecretKey) {
    throw new Error('Stripe secret key is required')
  }

  return config as SanitizedPluginConfig
}

// Use at plugin start
export const myPlugin =
  (pluginConfig: PluginConfig): Plugin =>
  (config) => {
    const sanitized = sanitizePluginConfig({ pluginConfig })
    // Use sanitized config throughout
  }

Collection Slug Mapping

Track collection slugs when users can override them:

type CollectionSlugMap = {
  products: string
  variants: string
  orders: string
}

const getCollectionSlugMap = ({ config }: { config: PluginConfig }): CollectionSlugMap => ({
  products: config.products?.slug || 'products',
  variants: config.variants?.slug || 'variants',
  orders: config.orders?.slug || 'orders',
})

// Use throughout plugin
const collectionSlugMap = getCollectionSlugMap({ config: pluginConfig })

// When creating relationship fields
{
  name: 'product',
  type: 'relationship',
  relationTo: collectionSlugMap.products,
}

Multi-Collection Configuration

Plugin operates on multiple collections with collection-specific config:

interface PluginConfig {
  sync: Array<{
    collection: string
    fields?: string[]
    onSync?: (doc: any) => Promise<void>
  }>
}

// In plugin
for (const collection of config.collections!) {
  const syncConfig = pluginConfig.sync?.find((s) => s.collection === collection.slug)
  if (!syncConfig) continue

  collection.hooks.afterChange = [
    ...(collection.hooks?.afterChange || []),
    async ({ doc, operation }) => {
      if (operation === 'create' || operation === 'update') {
        await syncConfig.onSync?.(doc)
      }
    },
  ]
}

TypeScript Extensions

TypeScript Schema Extension

Add custom properties to generated TypeScript schema:

incomingConfig.typescript = incomingConfig.typescript || {}
incomingConfig.typescript.schema = incomingConfig.typescript.schema || []

incomingConfig.typescript.schema.push((args) => {
  const { jsonSchema } = args

  jsonSchema.properties.ecommerce = {
    type: 'object',
    properties: {
      collections: {
        type: 'object',
        properties: {
          products: { type: 'string' },
          orders: { type: 'string' },
        },
      },
    },
  }

  return jsonSchema
})

Module Declaration Augmentation

Extend Payload types for plugin-specific field properties:

// In plugin types file
declare module 'payload' {
  export interface FieldCustom {
    'plugin-import-export'?: {
      disabled?: boolean
      toCSV?: (value: any) => string
      fromCSV?: (value: string) => any
    }
  }
}

// Usage with TypeScript support
{
  name: 'price',
  type: 'number',
  custom: {
    'plugin-import-export': {
      toCSV: (value) => `$${value.toFixed(2)}`,
      fromCSV: (value) => parseFloat(value.replace('$', '')),
    },
  },
}

Advanced Hooks

Global Error Hooks

Add global error handling:

return {
  ...config,
  hooks: {
    afterError: [
      ...(config.hooks?.afterError ?? []),
      async (args) => {
        const { error } = args
        const status = (error as APIError).status ?? 500

        if (status >= 500 || captureErrors.includes(status)) {
          captureException(error, {
            tags: {
              collection: args.collection?.slug,
              operation: args.operation,
            },
            user: args.req?.user ? { id: args.req.user.id } : undefined,
          })
        }
      },
    ],
  },
}

Multiple Hook Types on Same Collection

Coordinate multiple lifecycle hooks together for complex workflows (e.g., validation → sync → cache → cleanup):

collection.hooks = {
  ...collection.hooks,

  beforeValidate: [
    ...(collection.hooks?.beforeValidate || []),
    async ({ data }) => {
      // Normalize before validation
      return data
    },
  ],

  beforeChange: [
    ...(collection.hooks?.beforeChange || []),
    async ({ data, operation }) => {
      // Sync to external service
      if (operation === 'create') {
        data.externalId = await externalService.create(data)
      }
      return data
    },
  ],

  afterChange: [
    ...(collection.hooks?.afterChange || []),
    async ({ doc }) => {
      // Invalidate cache
      await cache.invalidate(`doc:${doc.id}`)
    },
  ],

  afterDelete: [
    ...(collection.hooks?.afterDelete || []),
    async ({ doc }) => {
      // Cleanup external resources
      await externalService.delete(doc.externalId)
    },
  ],
}

Access Control & Filtering

Access Control Wrapper Pattern

Wrap existing access control with plugin-specific logic:

// From plugin-multi-tenant
export const multiTenantPlugin =
  (pluginOptions: PluginOptions) =>
  (config: Config): Config => ({
    ...config,
    collections: (config.collections || []).map((collection) => {
      if (!pluginOptions.collections.includes(collection.slug)) {
        return collection
      }

      return {
        ...collection,
        access: {
          ...collection.access,
          read: ({ req }) => {
            // Inject tenant filter
            return {
              and: [
                collection.access?.read ? collection.access.read({ req }) : {},
                { tenant: { equals: req.user?.tenant } },
              ],
            }
          },
        },
      }
    }),
  })

BaseFilter Composition

Combine plugin filters with existing baseListFilter:

// From plugin-multi-tenant
const existingBaseFilter = collection.admin?.baseListFilter
const tenantFilter = { tenant: { equals: req.user?.tenant } }

collection.admin = {
  ...collection.admin,
  baseListFilter: existingBaseFilter ? { and: [existingBaseFilter, tenantFilter] } : tenantFilter,
}

Relationship FilterOptions Modification

Add filters to relationship field options:

// From plugin-multi-tenant
collection.fields = collection.fields.map((field) => {
  if (field.type === 'relationship') {
    return {
      ...field,
      filterOptions: ({ relationTo }) => {
        return {
          and: [field.filterOptions?.(relationTo) || {}, { tenant: { equals: req.user?.tenant } }],
        }
      },
    }
  }
  return field
})

Admin UI Customization

Metadata Storage Pattern

Use admin.meta for plugin-specific UI state without database fields:

// From plugin-nested-docs
export const nestedDocsPlugin =
  (pluginOptions: PluginOptions) =>
  (config: Config): Config => ({
    ...config,
    collections: config.collections?.map((collection) => ({
      ...collection,
      admin: {
        ...collection.admin,
        meta: {
          ...collection.admin?.meta,
          nestedDocs: {
            breadcrumbsFieldSlug: pluginOptions.breadcrumbsFieldSlug || 'breadcrumbs',
            parentFieldSlug: pluginOptions.parentFieldSlug || 'parent',
          },
        },
      },
    })),
  })

Conditional Component Rendering

Add components based on plugin configuration:

// From plugin-seo
const beforeFields = collection.admin?.components?.beforeFields || []

if (pluginOptions.uploadsCollection === collection.slug) {
  beforeFields.push('/path/to/ImagePreview#ImagePreview')
}

collection.admin = {
  ...collection.admin,
  components: {
    ...collection.admin?.components,
    beforeFields,
  },
}

Custom Provider Pattern

Inject context providers for shared state:

// From plugin-nested-docs
collection.admin = {
  ...collection.admin,
  components: {
    ...collection.admin?.components,
    providers: [
      ...(collection.admin?.components?.providers || []),
      '/components/NestedDocsProvider#NestedDocsProvider',
    ],
  },
}

Custom Actions

Add collection-level action buttons:

// From plugin-import-export
collection.admin = {
  ...collection.admin,
  components: {
    ...collection.admin?.components,
    actions: [
      ...(collection.admin?.components?.actions || []),
      '/components/ImportButton#ImportButton',
      '/components/ExportButton#ExportButton',
    ],
  },
}

Custom List Item Views

Modify how items appear in collection lists:

// From plugin-ecommerce
collection.admin = {
  ...collection.admin,
  components: {
    ...collection.admin?.components,
    views: {
      ...collection.admin?.components?.views,
      list: {
        ...collection.admin?.components?.views?.list,
        Component: '/views/ProductList#ProductList',
      },
    },
  },
}

Custom Collection Endpoints

Add collection-scoped endpoints (accessible at /api/<collection-slug>/<path>):

// From plugin-import-export
collection.endpoints = [
  ...(collection.endpoints || []),
  {
    path: '/import',
    method: 'post',
    handler: async (req) => {
      // Import logic accessible at /api/posts/import
      return Response.json({ success: true })
    },
  },
  {
    path: '/export',
    method: 'get',
    handler: async (req) => {
      // Export logic accessible at /api/posts/export
      return Response.json({ data: exportedData })
    },
  },
]

Field & Collection Modifications

Admin Folders Override

Control admin UI organization:

// From plugin-redirects
collection.admin = {
  ...collection.admin,
  group: pluginOptions.group || 'Settings',
  hidden: pluginOptions.hidden,
  defaultColumns: pluginOptions.defaultColumns || ['from', 'to', 'updatedAt'],
}

Background Jobs & Async Operations

Jobs Registration

Register plugin background tasks:

// From plugin-stripe
export const stripePlugin =
  (pluginOptions: PluginOptions) =>
  (config: Config): Config => ({
    ...config,
    jobs: {
      ...config.jobs,
      tasks: [
        ...(config.jobs?.tasks || []),
        {
          slug: 'syncStripeProducts',
          handler: async ({ req }) => {
            const products = await stripe.products.list()
            // Sync to Payload
            return { output: { synced: products.data.length } }
          },
        },
      ],
    },
  })

Testing Plugins

Local Development with dev/ Directory (optional)

Include a dev/ directory with a complete Payload project for local development:

  1. Create dev/.env from .env.example:
DATABASE_URL=mongodb://127.0.0.1/plugin-dev
PAYLOAD_SECRET=your-secret-here
  1. Configure dev/payload.config.ts:
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { myPlugin } from '../src/index.js'

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET!,
  db: mongooseAdapter({ url: process.env.DATABASE_URL! }),
  plugins: [
    myPlugin({
      collections: ['posts'],
    }),
  ],
  collections: [
    {
      slug: 'posts',
      fields: [{ name: 'title', type: 'text' }],
    },
  ],
})
  1. Run development server:
npm run dev  # Starts Next.js on http://localhost:3000

Integration Tests (Vitest) (optional)

Create dev/int.spec.ts:

import type { Payload } from 'payload'
import config from '@payload-config'
import { createPayloadRequest, getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { customEndpointHandler } from '../src/endpoints/handler.js'

let payload: Payload

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

afterAll(async () => {
  await payload.destroy()
})

describe('Plugin integration tests', () => {
  test('should add field to collection', async () => {
    const post = await payload.create({
      collection: 'posts',
      data: {
        title: 'Test',
        addedByPlugin: 'plugin value',
      },
    })
    expect(post.addedByPlugin).toBe('plugin value')
  })

  test('should create plugin collection', async () => {
    expect(payload.collections['plugin-collection']).toBeDefined()
    const { docs } = await payload.find({ collection: 'plugin-collection' })
    expect(docs.length).toBeGreaterThan(0)
  })

  test('should query custom endpoint', async () => {
    const request = new Request('http://localhost:3000/api/my-endpoint')
    const payloadRequest = await createPayloadRequest({ config, request })
    const response = await customEndpointHandler(payloadRequest)
    const data = await response.json()
    expect(data).toMatchObject({ message: 'Hello' })
  })
})

Run: npm run test:int

End-to-End Tests (Playwright)

Create dev/e2e.spec.ts:

import { test, expect } from '@playwright/test'

test.describe('Plugin e2e tests', () => {
  test('should render custom admin component', async ({ page }) => {
    await page.goto('http://localhost:3000/admin')
    await expect(page.getByText('Added by the plugin')).toBeVisible()
  })
})

Run: npm run test:e2e

Common Plugin Types

Field Enhancer

Adds fields to existing collections (SEO, timestamps, audit logs)

Collection Provider

Adds new collections (redirects, forms, logs)

Hook Injector

Adds hooks to collections (nested docs, cache invalidation)

UI Enhancer

Adds custom components (dashboards, field types)

Integration

Connects external services (Stripe, Sentry, storage adapters)

Adapter

Provides infrastructure (database, storage, email)

Resources