obsidian/wiki/payloadcms/plugins-api.md
2026-05-15 16:15:30 +01:00

7.9 KiB
Raw Permalink Blame History

tags topic sources created
payloadcms
tech-patterns
payloadcms
plugins__plugin-api.md
plugins__build-your-own.md
2026-05-15

PayloadCMS — Plugin API

Overview

  • A plugin is a function: (incomingConfig: Config) => Config
  • Core contract is permanent — simple functions always work
  • Advanced API adds execution ordering (order) and typed cross-plugin communication (RegisteredPlugins)
  • Advanced API is experimental — safe to use but surface may change
  • See wiki/payloadcms/plugins for ready-made plugins

Plugin Function Signature

Minimal (always works):

import type { Config, Plugin } from 'payload'

export const myPlugin =
  (opts: MyOptions): Plugin =>
  (incomingConfig: Config): Config => ({
    ...incomingConfig,
    collections: [...(incomingConfig.collections || []), myCollection],
  })

With definePlugin (recommended for published packages):

import { definePlugin } from 'payload'

export const myPlugin = definePlugin<MyOptions>({
  slug: 'my-plugin',   // unique identifier for cross-plugin communication
  order: 10,           // lower = runs first; default 0
  plugin: ({ config, plugins, ...opts }) => ({
    ...config,
    collections: [...(config.collections || []), myCollection],
  }),
})

// Usage in payload.config.ts:
plugins: [myPlugin({ /* options */ })]

What Plugins Can Do

By spreading the incoming config and returning a modified version, plugins can:

  • Add or modify collections and globals
  • Inject fields into existing collections
  • Register wiki/payloadcms/hooks (beforeChange, afterRead, etc.)
  • Add custom endpoints
  • Add custom admin views and components
  • Extend onInit (must call existing onInit first)
  • Add GraphQL queries/mutations
  • Add custom metadata to the config

Spread Syntax — Critical Rule

Always spread existing arrays/objects; never replace them:

// Collections
config.collections = [
  ...(config.collections || []),
  newCollection,
]

// Globals
config.globals = [
  ...(config.globals || []),
  newGlobal,
]

// Hooks (object — spread differently)
config.hooks = {
  ...(config.hooks || {}),
  afterError: [...(config.hooks?.afterError || []), myHook],
}

// onInit — must chain, cannot spread
config.onInit = async (payload) => {
  if (incomingConfig.onInit) await incomingConfig.onInit(payload)
  // your logic here
}

Example Plugin

Full lifecycle example — adds a lastModifiedBy relationship field to all auth-enabled collections:

import type { Config, Plugin } from 'payload'

export const addLastModified: Plugin = (incomingConfig: Config): Config => {
  const authCollections = incomingConfig.collections.filter(
    (c) => Boolean(c.auth),
  )

  return {
    ...incomingConfig,
    collections: incomingConfig.collections.map((collection) => ({
      ...collection,
      fields: [
        ...collection.fields,
        {
          name: 'lastModifiedBy',
          type: 'relationship',
          relationTo: authCollections.map(({ slug }) => slug),
          hooks: {
            beforeChange: [
              ({ req }) => ({
                value: req?.user?.id,
                relationTo: req?.user?.collection,
              }),
            ],
          },
          admin: { position: 'sidebar', readOnly: true },
        },
      ],
    })),
  }
}

Execution Ordering with order

Negative   → must run before everything (polyfills, normalization)
0          → default — no dependencies
1050      → depends on collections/fields added by other plugins
100+       → must run last (audit, introspection, final-config plugins)
plugins: [
  analyticsPlugin(), // order: 10 — runs second
  basePlugin(),      // order: 1  — runs first (regardless of array position)
]

Cross-Plugin Communication

Pattern: a plugin exposes its options; another plugin mutates those options before the first runs.

// writer plugin (order: 1) — runs first, injects into reader's options
export const writerPlugin = definePlugin({
  slug: 'my-writer',
  order: 1,
  plugin: ({ config, plugins }) => {
    plugins['my-reader']?.options?.items.push({ name: 'injected' })
    return config
  },
})

// reader plugin (order: 10) — runs second, sees injected item
export const readerPlugin = definePlugin<{ items: Array<{ name: string }> }>({
  slug: 'my-reader',
  order: 10,
  plugin: ({ config, items }) => ({
    ...config,
    custom: { ...config.custom, items: items.map((i) => i.name) },
  }),
})

// payload.config.ts
plugins: [readerPlugin({ items: [{ name: 'user-provided' }] }), writerPlugin()]
// Result: reader sees ['user-provided', 'injected']

TypeScript: RegisteredPlugins Augmentation

Plugin packages can register their slug and options type so the plugins map is fully typed — no codegen, activated at import time:

// packages/my-plugin/src/index.ts
export type MyPluginOptions = { collections: string[] }

export const myPlugin = definePlugin<MyPluginOptions>({
  slug: 'my-plugin',
  order: 10,
  plugin: ({ config, collections }) => ({ ...config }),
})

declare module 'payload' {
  interface RegisteredPlugins {
    'my-plugin': MyPluginOptions
  }
}

Any project that imports my-plugin gets typed access in the plugins map — plugins['my-plugin']?.options?.collections is typed as string[].

Plugin Template

Scaffold a new plugin with the official template:

npx create-payload-app@latest --template plugin

Template structure:

  • / — root config (package.json, README, tsconfig)
  • /src — plugin source code
  • /dev — local Payload dev environment for testing

Start dev:

cd dev && pnpm dev   # http://localhost:3000

Run tests (Jest):

cd dev && pnpm test

Seeding Data in Dev Environment

For development and testing, seed the database via PAYLOAD_SEED=true:

// dev/src/server.ts
if (process.env.PAYLOAD_SEED === 'true') {
  await seed(payload)
}

// dev/src/seed.ts
export const seed = async (payload: Payload): Promise<void> => {
  payload.logger.info('Seeding data...')
  await payload.create({
    collection: 'new-collection',
    data: { title: 'Seeded title' },
  })
}

Best Practices

  • Enable/disable option — provide enabled?: boolean so users can disable without uninstalling
  • TypeScript types — export PluginTypes interface with JSDoc comments for IDE support
  • Test suite — Jest in dev/ folder; integrate into GitHub CI
  • SemVer — major version should indicate Payload compatibility
  • npm publish — publish with payload-plugin GitHub topic for discoverability
  • Spread everything — never mutate incoming config directly; always spread
  • Chain functions — for onInit, onInit, etc. call the existing function first

Checking If a Plugin Is Installed

From outside a plugin:

const hasMySeoPlugin = config.plugins?.some((p) => p.slug === 'plugin-seo') ?? false

From inside a definePlugin function:

plugin: ({ config, plugins }) => {
  if (plugins['plugin-seo']) {
    // plugin-seo is installed — safe to mutate its options
  }
  return config
}

Gotchas

  • Plugins execute after config validation but before defaults are merged — avoid relying on default values
  • If you override name of parent or breadcrumbs fields in nested-docs, set parentFieldSlug/breadcrumbsFieldSlug accordingly
  • onInit must always chain the existing function or other plugins' onInit logic is lost
  • Cross-plugin mutation works because options are resolved before any plugin executes — no re-execution needed
  • definePlugin result is a factory; call it with options to get a Plugin: myPlugin({ option: true })
  • Initialization order: validate → plugins execute → defaults merge → sanitize → final config