1437 lines
34 KiB
Markdown
1437 lines
34 KiB
Markdown
# 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:
|
|
|
|
```ts
|
|
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
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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>`):
|
|
|
|
```ts
|
|
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:**
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:**
|
|
|
|
```tsx
|
|
// 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)
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```tsx
|
|
// 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>
|
|
)
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// ✅ Good
|
|
collections: [...(config.collections || []), newCollection]
|
|
|
|
// ❌ Bad
|
|
collections: [newCollection]
|
|
```
|
|
|
|
### Respect User Overrides
|
|
|
|
Allow users to override plugin defaults:
|
|
|
|
```ts
|
|
const collection: CollectionConfig = {
|
|
slug: 'redirects',
|
|
fields: defaultFields,
|
|
...options.overrides, // User overrides last
|
|
}
|
|
```
|
|
|
|
### Conditional Logic
|
|
|
|
Check if collections/globals are enabled:
|
|
|
|
```ts
|
|
collections: config.collections?.map((collection) => {
|
|
const isEnabled = options.collections?.includes(collection.slug)
|
|
if (isEnabled) {
|
|
// Transform collection
|
|
}
|
|
return collection
|
|
})
|
|
```
|
|
|
|
### Hook Composition
|
|
|
|
Preserve existing hooks:
|
|
|
|
```ts
|
|
hooks: {
|
|
...collection.hooks,
|
|
afterChange: [
|
|
myHook,
|
|
...(collection.hooks?.afterChange || []),
|
|
],
|
|
}
|
|
```
|
|
|
|
### Type Safety
|
|
|
|
Use Payload's exported types:
|
|
|
|
```ts
|
|
import type { Config, Plugin, CollectionConfig, Field, CollectionSlug, GlobalSlug } from 'payload'
|
|
```
|
|
|
|
### Field Path Imports
|
|
|
|
Use absolute paths for client components:
|
|
|
|
```ts
|
|
admin: {
|
|
components: {
|
|
Field: '/fields/CustomField/Component#CustomFieldComponent',
|
|
},
|
|
}
|
|
```
|
|
|
|
### onInit Pattern
|
|
|
|
Always call existing `onInit` before your initialization. See [onInit Hook](#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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// 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>`):
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// 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`:
|
|
|
|
```bash
|
|
DATABASE_URL=mongodb://127.0.0.1/plugin-dev
|
|
PAYLOAD_SECRET=your-secret-here
|
|
```
|
|
|
|
2. Configure `dev/payload.config.ts`:
|
|
|
|
```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' }],
|
|
},
|
|
],
|
|
})
|
|
```
|
|
|
|
3. Run development server:
|
|
|
|
```bash
|
|
npm run dev # Starts Next.js on http://localhost:3000
|
|
```
|
|
|
|
### Integration Tests (Vitest) (optional)
|
|
|
|
Create `dev/int.spec.ts`:
|
|
|
|
```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`:
|
|
|
|
```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
|
|
|
|
- [Plugin Examples](https://github.com/payloadcms/payload/tree/main/packages/) - Official plugins source code, payload-\* prefix
|
|
- [Plugin Template](https://github.com/payloadcms/payload/tree/main/templates/plugin) - Starter template for new plugins
|