tools/claude-plugin/skills/payload/reference/PLUGIN-DEVELOPMENT.md
Complete guide to creating Payload CMS plugins with TypeScript patterns, package structure, and best practices from the official Payload plugin template.
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)
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
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:
{
"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./dist, source in ./src/types, /client, /rsc/client for client components, /rsc for React Server ComponentsprepublishOnly ensures build before publishimport 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
}),
}
}
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],
}
}
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
}),
})
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 })
},
}
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
}),
}
}
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],
}
}),
}
}
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
}
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'
// 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 ?? {}),
},
})
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
}
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>
}
// src/exports/types.ts
export type { MyPluginConfig, FieldsOverride } from '../types.js'
// Usage
import type { MyPluginConfig } from '@payloadcms/plugin-example/types'
// 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,
})
Always spread existing config and add to arrays:
// ✅ Good
collections: [...(config.collections || []), newCollection]
// ❌ Bad
collections: [newCollection]
Allow users to override plugin defaults:
const collection: CollectionConfig = {
slug: 'redirects',
fields: defaultFields,
...options.overrides, // User overrides last
}
Check if collections/globals are enabled:
collections: config.collections?.map((collection) => {
const isEnabled = options.collections?.includes(collection.slug)
if (isEnabled) {
// Transform collection
}
return collection
})
Preserve existing hooks:
hooks: {
...collection.hooks,
afterChange: [
myHook,
...(collection.hooks?.afterChange || []),
],
}
Use Payload's exported types:
import type { Config, Plugin, CollectionConfig, Field, CollectionSlug, GlobalSlug } from 'payload'
Use absolute paths for client components:
admin: {
components: {
Field: '/fields/CustomField/Component#CustomFieldComponent',
},
}
Always call existing onInit before your initialization. See onInit Hook pattern for full example.
These patterns are extracted from official Payload plugins and represent production-ready techniques for complex plugin development.
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],
}
}
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
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
}
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,
}
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)
}
},
]
}
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
})
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('$', '')),
},
},
}
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,
})
}
},
],
},
}
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)
},
],
}
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 } },
],
}
},
},
}
}),
})
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,
}
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
})
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',
},
},
},
})),
})
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,
},
}
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',
],
},
}
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',
],
},
}
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',
},
},
},
}
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 })
},
},
]
Control admin UI organization:
// From plugin-redirects
collection.admin = {
...collection.admin,
group: pluginOptions.group || 'Settings',
hidden: pluginOptions.hidden,
defaultColumns: pluginOptions.defaultColumns || ['from', 'to', 'updatedAt'],
}
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 } }
},
},
],
},
})
Include a dev/ directory with a complete Payload project for local development:
dev/.env from .env.example:DATABASE_URL=mongodb://127.0.0.1/plugin-dev
PAYLOAD_SECRET=your-secret-here
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' }],
},
],
})
npm run dev # Starts Next.js on http://localhost:3000
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
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
Adds fields to existing collections (SEO, timestamps, audit logs)
Adds new collections (redirects, forms, logs)
Adds hooks to collections (nested docs, cache invalidation)
Adds custom components (dashboards, field types)
Connects external services (Stripe, Sentry, storage adapters)
Provides infrastructure (database, storage, email)