Add configuration for BMad, Claude, OpenCode, and other AI agent tools and workflows.
46 KiB
name, description, author, version
| name | description | author | version |
|---|---|---|---|
| payload-cms | Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Triggers on tasks involving: collection definitions, field configurations, hooks, access control, database queries, custom endpoints, authentication, file uploads, drafts/versions, live preview, or plugin development. Also use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior. | payloadcms | 1.0.0 |
Payload CMS Development
Payload is a Next.js native CMS with TypeScript-first architecture. This skill transfers expert knowledge for building collections, hooks, access control, and queries the right way.
Mental Model
Think of Payload as three interconnected layers:
- Config Layer → Collections, globals, fields define your schema
- Hook Layer → Lifecycle events transform and validate data
- Access Layer → Functions control who can do what
Every operation flows through: Config → Access Check → Hook Chain → Database → Response Hooks
Quick Reference
| Task | Solution | Details |
|---|---|---|
| Auto-generate slugs | slugField() or beforeChange hook |
[references/fields.md#slug-field] |
| Restrict by user | Access control with query constraint | [references/access-control.md] |
| Local API with auth | user + overrideAccess: false |
[references/queries.md#local-api] |
| Draft/publish | versions: { drafts: true } |
[references/collections.md#drafts] |
| Computed fields | virtual: true with afterRead hook |
[references/fields.md#virtual] |
| Conditional fields | admin.condition |
[references/fields.md#conditional] |
| Filter relationships | filterOptions on field |
[references/fields.md#relationship] |
| Prevent hook loops | req.context flag |
[references/hooks.md#context] |
| Transactions | Pass req to all operations |
[references/hooks.md#transactions] |
| Background jobs | Jobs queue with tasks | [references/advanced.md#jobs] |
Quick Start
npx create-payload-app@latest my-app
cd my-app
pnpm dev
Minimal Config
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export default buildConfig({
admin: { user: 'users' },
collections: [Users, Media, Posts],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET,
typescript: { outputFile: 'payload-types.ts' },
db: mongooseAdapter({ url: process.env.DATABASE_URL }),
})
Core Patterns
Collection Definition
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'createdAt'],
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true, index: true },
{ name: 'content', type: 'richText' },
{ name: 'author', type: 'relationship', relationTo: 'users' },
{ name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft' },
],
timestamps: true,
}
Hook Pattern (Auto-slug)
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
beforeChange: [
async ({ data, operation }) => {
if (operation === 'create' && data.title) {
data.slug = data.title.toLowerCase().replace(/\s+/g, '-')
}
return data
},
],
},
fields: [{ name: 'title', type: 'text', required: true }],
}
Access Control Pattern
import type { Access } from 'payload'
// Type-safe: admin-only access
export const adminOnly: Access = ({ req }) => {
return req.user?.roles?.includes('admin') ?? false
}
// Row-level: users see only their own posts
export const ownPostsOnly: Access = ({ req }) => {
if (!req.user) return false
if (req.user.roles?.includes('admin')) return true
return { author: { equals: req.user.id } }
}
Query Pattern
// Local API with access control
const posts = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
'author.name': { contains: 'john' },
},
depth: 2,
limit: 10,
sort: '-createdAt',
user: req.user,
overrideAccess: false, // CRITICAL: enforce permissions
})
Critical Security Rules
1. Local API Access Control
Default behavior bypasses ALL access control. This is the #1 security mistake.
// ❌ SECURITY BUG: Access control bypassed even with user
await payload.find({ collection: 'posts', user: someUser })
// ✅ SECURE: Explicitly enforce permissions
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // REQUIRED
})
Rule: Use overrideAccess: false for any operation acting on behalf of a user.
2. Transaction Integrity
Operations without req run in separate transactions.
// ❌ DATA CORRUPTION: Separate transaction
hooks: {
afterChange: [async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
// Missing req - breaks atomicity!
})
}]
}
// ✅ ATOMIC: Same transaction
hooks: {
afterChange: [async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
req, // Maintains transaction
})
}]
}
Rule: Always pass req to nested operations in hooks.
3. Infinite Hook Loops
Hooks triggering themselves create infinite loops.
// ❌ INFINITE LOOP
hooks: {
afterChange: [async ({ doc, req }) => {
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { views: doc.views + 1 },
req,
}) // Triggers afterChange again!
}]
}
// ✅ SAFE: Context flag breaks the loop
hooks: {
afterChange: [async ({ doc, req, context }) => {
if (context.skipViewUpdate) return
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { views: doc.views + 1 },
req,
context: { skipViewUpdate: true },
})
}]
}
Project Structure
src/
├── app/
│ ├── (frontend)/page.tsx
│ └── (payload)/admin/[[...segments]]/page.tsx
├── collections/
│ ├── Posts.ts
│ ├── Media.ts
│ └── Users.ts
├── globals/Header.ts
├── hooks/slugify.ts
└── payload.config.ts
Type Generation
Generate types after schema changes:
// payload.config.ts
export default buildConfig({
typescript: { outputFile: 'payload-types.ts' },
})
// Usage
import type { Post, User } from '@/payload-types'
Getting Payload Instance
// In API routes
import { getPayload } from 'payload'
import config from '@payload-config'
export async function GET() {
const payload = await getPayload({ config })
const posts = await payload.find({ collection: 'posts' })
return Response.json(posts)
}
// In Server Components
export default async function Page() {
const payload = await getPayload({ config })
const { docs } = await payload.find({ collection: 'posts' })
return <div>{docs.map(p => <h1 key={p.id}>{p.title}</h1>)}</div>
}
Common Field Types
// Text
{ name: 'title', type: 'text', required: true }
// Relationship
{ name: 'author', type: 'relationship', relationTo: 'users' }
// Rich text
{ name: 'content', type: 'richText' }
// Select
{ name: 'status', type: 'select', options: ['draft', 'published'] }
// Upload
{ name: 'image', type: 'upload', relationTo: 'media' }
// Array
{
name: 'tags',
type: 'array',
fields: [{ name: 'tag', type: 'text' }],
}
// Blocks (polymorphic content)
{
name: 'layout',
type: 'blocks',
blocks: [HeroBlock, ContentBlock, CTABlock],
}
Decision Framework
When choosing between approaches:
| Scenario | Approach |
|---|---|
| Data transformation before save | beforeChange hook |
| Data transformation after read | afterRead hook |
| Enforce business rules | Access control function |
| Complex validation | validate function on field |
| Computed display value | Virtual field with afterRead |
| Related docs list | join field type |
| Side effects (email, webhook) | afterChange hook with context guard |
| Database-level constraint | Field with unique: true or index: true |
Quality Checks
Good Payload code:
- All Local API calls with user context use
overrideAccess: false - All hook operations pass
reqfor transaction integrity - Recursive hooks use
contextflags - Types generated and imported from
payload-types.ts - Access control functions are typed with
Accesstype - Collections have meaningful
admin.useAsTitleset
Reference Documentation
For detailed patterns, see:
- references/fields.md - All field types, validation, conditional logic
- references/collections.md - Auth, uploads, drafts, live preview
- references/hooks.md - Hook lifecycle, context, patterns
- references/access-control.md - RBAC, row-level, field-level
- references/queries.md - Operators, Local/REST/GraphQL APIs
- references/advanced.md - Jobs, plugins, localization
Resources
- Docs: https://payloadcms.com/docs
- LLM Context: https://payloadcms.com/llms-full.txt
- GitHub: https://github.com/payloadcms/payload
- Templates: https://github.com/payloadcms/payload/tree/main/templates -e
Detailed Reference Documentation
Field Types Reference
Core Field Types
Text Fields
// Basic text
{ name: 'title', type: 'text', required: true }
// With validation
{
name: 'email',
type: 'text',
validate: (value) => {
if (!value?.includes('@')) return 'Invalid email'
return true
},
}
// With admin config
{
name: 'description',
type: 'textarea',
admin: {
placeholder: 'Enter description...',
description: 'Brief summary',
},
}
Slug Field Helper
Auto-generate URL-safe slugs:
import { slugField } from '@payloadcms/plugin-seo'
// Or manual implementation
{
name: 'slug',
type: 'text',
unique: true,
index: true,
hooks: {
beforeValidate: [
({ data, operation, originalDoc }) => {
if (operation === 'create' || !originalDoc?.slug) {
return data?.title?.toLowerCase().replace(/\s+/g, '-')
}
return originalDoc.slug
},
],
},
}
Number Fields
{ name: 'price', type: 'number', min: 0, required: true }
{ name: 'quantity', type: 'number', defaultValue: 1 }
Select Fields
// Simple select
{
name: 'status',
type: 'select',
options: ['draft', 'published', 'archived'],
defaultValue: 'draft',
}
// With labels
{
name: 'priority',
type: 'select',
options: [
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
],
}
// Multi-select
{
name: 'categories',
type: 'select',
hasMany: true,
options: ['tech', 'design', 'marketing'],
}
Checkbox
{ name: 'featured', type: 'checkbox', defaultValue: false }
Date Fields
{ name: 'publishedAt', type: 'date' }
// With time
{
name: 'eventDate',
type: 'date',
admin: { date: { pickerAppearance: 'dayAndTime' } },
}
Relationship Fields
Basic Relationship
// Single relationship
{
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
}
// Multiple relationships (hasMany)
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
}
// Polymorphic (multiple collections)
{
name: 'parent',
type: 'relationship',
relationTo: ['pages', 'posts'],
}
With Filter Options
Dynamically filter available options:
{
name: 'relatedPosts',
type: 'relationship',
relationTo: 'posts',
hasMany: true,
filterOptions: ({ data }) => ({
// Only show published posts, exclude self
status: { equals: 'published' },
id: { not_equals: data?.id },
}),
}
Join Fields
Reverse relationship lookup (virtual field):
// In Posts collection
{
name: 'comments',
type: 'join',
collection: 'comments',
on: 'post', // field name in comments that references posts
}
Virtual Fields
Computed fields that don't store data:
{
name: 'fullName',
type: 'text',
virtual: true,
hooks: {
afterRead: [
({ data }) => `${data?.firstName} ${data?.lastName}`,
],
},
}
Conditional Fields
Show/hide fields based on other values:
{
name: 'isExternal',
type: 'checkbox',
},
{
name: 'externalUrl',
type: 'text',
admin: {
condition: (data) => data?.isExternal === true,
},
}
Validation
Custom Validation
{
name: 'slug',
type: 'text',
validate: (value, { data, operation }) => {
if (!value) return 'Slug is required'
if (!/^[a-z0-9-]+$/.test(value)) {
return 'Slug must be lowercase letters, numbers, and hyphens only'
}
return true
},
}
Async Validation
{
name: 'username',
type: 'text',
validate: async (value, { payload }) => {
if (!value) return true
const existing = await payload.find({
collection: 'users',
where: { username: { equals: value } },
})
if (existing.docs.length > 0) return 'Username already taken'
return true
},
}
Group Fields
Organize related fields:
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
],
}
Array Fields
Repeatable sets of fields:
{
name: 'socialLinks',
type: 'array',
fields: [
{ name: 'platform', type: 'select', options: ['twitter', 'linkedin', 'github'] },
{ name: 'url', type: 'text' },
],
}
Blocks (Polymorphic Content)
Different content types in same array:
{
name: 'layout',
type: 'blocks',
blocks: [
{
slug: 'hero',
fields: [
{ name: 'heading', type: 'text' },
{ name: 'image', type: 'upload', relationTo: 'media' },
],
},
{
slug: 'content',
fields: [
{ name: 'richText', type: 'richText' },
],
},
],
}
Point (Geolocation)
{
name: 'location',
type: 'point',
label: 'Location',
}
// Query nearby
await payload.find({
collection: 'stores',
where: {
location: {
near: [-73.935242, 40.730610, 5000], // lng, lat, maxDistance (meters)
},
},
})
Upload Fields
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
required: true,
}
Rich Text
{
name: 'content',
type: 'richText',
// Lexical editor features configured in payload.config.ts
}
UI Fields (Presentational)
Fields that don't save data:
// Row layout
{
type: 'row',
fields: [
{ name: 'firstName', type: 'text', admin: { width: '50%' } },
{ name: 'lastName', type: 'text', admin: { width: '50%' } },
],
}
// Tabs
{
type: 'tabs',
tabs: [
{ label: 'Content', fields: [...] },
{ label: 'Meta', fields: [...] },
],
}
// Collapsible
{
type: 'collapsible',
label: 'Advanced Options',
fields: [...],
}
-e
Collections Reference
Basic Collection Config
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'createdAt'],
group: 'Content', // Groups in sidebar
},
fields: [...],
timestamps: true, // Adds createdAt, updatedAt
}
Auth Collection
Enable authentication on a collection:
export const Users: CollectionConfig = {
slug: 'users',
auth: {
tokenExpiration: 7200, // 2 hours
verify: true, // Email verification
maxLoginAttempts: 5,
lockTime: 600 * 1000, // 10 min lockout
},
fields: [
{ name: 'name', type: 'text', required: true },
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
defaultValue: ['user'],
},
],
}
Upload Collection
Handle file uploads:
export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: 'media',
mimeTypes: ['image/*', 'application/pdf'],
imageSizes: [
{ name: 'thumbnail', width: 400, height: 300, position: 'centre' },
{ name: 'card', width: 768, height: 1024, position: 'centre' },
],
adminThumbnail: 'thumbnail',
},
fields: [
{ name: 'alt', type: 'text', required: true },
{ name: 'caption', type: 'textarea' },
],
}
Versioning & Drafts
Enable draft/publish workflow:
export const Posts: CollectionConfig = {
slug: 'posts',
versions: {
drafts: true,
maxPerDoc: 10, // Keep last 10 versions
},
fields: [...],
}
Query drafts:
// Get published only (default)
await payload.find({ collection: 'posts' })
// Include drafts
await payload.find({ collection: 'posts', draft: true })
Live Preview
Real-time preview for frontend:
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
livePreview: {
url: ({ data }) => `${process.env.NEXT_PUBLIC_URL}/preview/${data.slug}`,
},
},
versions: { drafts: true },
fields: [...],
}
Access Control
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: ({ req }) => !!req.user, // Logged in users
read: () => true, // Public read
update: ({ req }) => req.user?.roles?.includes('admin'),
delete: ({ req }) => req.user?.roles?.includes('admin'),
},
fields: [...],
}
Hooks Configuration
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
beforeValidate: [...],
beforeChange: [...],
afterChange: [...],
beforeRead: [...],
afterRead: [...],
beforeDelete: [...],
afterDelete: [...],
// Auth-only hooks
afterLogin: [...],
afterLogout: [...],
afterMe: [...],
afterRefresh: [...],
afterForgotPassword: [...],
},
fields: [...],
}
Custom Endpoints
Add API routes to a collection:
export const Posts: CollectionConfig = {
slug: 'posts',
endpoints: [
{
path: '/publish/:id',
method: 'post',
handler: async (req) => {
const { id } = req.routeParams
await req.payload.update({
collection: 'posts',
id,
data: { status: 'published', publishedAt: new Date() },
req,
})
return Response.json({ success: true })
},
},
],
fields: [...],
}
Admin Panel Options
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'status', 'createdAt'],
group: 'Content',
description: 'Manage blog posts',
hidden: false, // Hide from sidebar
listSearchableFields: ['title', 'slug'],
pagination: {
defaultLimit: 20,
limits: [10, 20, 50, 100],
},
preview: (doc) => `${process.env.NEXT_PUBLIC_URL}/${doc.slug}`,
},
fields: [...],
}
Labels & Localization
export const Posts: CollectionConfig = {
slug: 'posts',
labels: {
singular: 'Article',
plural: 'Articles',
},
fields: [...],
}
Database Indexes
export const Posts: CollectionConfig = {
slug: 'posts',
fields: [
{ name: 'slug', type: 'text', unique: true, index: true },
{ name: 'publishedAt', type: 'date', index: true },
],
// Compound indexes via dbName
dbName: 'posts',
}
Disable Operations
export const AuditLogs: CollectionConfig = {
slug: 'audit-logs',
admin: {
enableRichTextRelationship: false,
},
disableDuplicate: true, // No duplicate button
fields: [...],
}
Full Example
import type { CollectionConfig } from 'payload'
import { slugField } from './fields/slugField'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'publishedAt'],
group: 'Content',
livePreview: {
url: ({ data }) => `${process.env.NEXT_PUBLIC_URL}/posts/${data.slug}`,
},
},
access: {
create: ({ req }) => !!req.user,
read: ({ req }) => {
if (req.user?.roles?.includes('admin')) return true
return { status: { equals: 'published' } }
},
update: ({ req }) => {
if (req.user?.roles?.includes('admin')) return true
return { author: { equals: req.user?.id } }
},
delete: ({ req }) => req.user?.roles?.includes('admin'),
},
versions: {
drafts: true,
maxPerDoc: 10,
},
hooks: {
beforeChange: [
async ({ data, operation }) => {
if (operation === 'create') {
data.slug = data.title?.toLowerCase().replace(/\s+/g, '-')
}
if (data.status === 'published' && !data.publishedAt) {
data.publishedAt = new Date()
}
return data
},
],
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true, index: true },
{ name: 'content', type: 'richText', required: true },
{
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
defaultValue: ({ user }) => user?.id,
},
{
name: 'status',
type: 'select',
options: ['draft', 'published', 'archived'],
defaultValue: 'draft',
},
{ name: 'publishedAt', type: 'date' },
{ name: 'featuredImage', type: 'upload', relationTo: 'media' },
{
name: 'categories',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
},
],
timestamps: true,
}
-e
Hooks Reference
Hook Lifecycle
Operation: CREATE
beforeOperation → beforeValidate → beforeChange → [DB Write] → afterChange → afterOperation
Operation: UPDATE
beforeOperation → beforeValidate → beforeChange → [DB Write] → afterChange → afterOperation
Operation: READ
beforeOperation → beforeRead → [DB Read] → afterRead → afterOperation
Operation: DELETE
beforeOperation → beforeDelete → [DB Delete] → afterDelete → afterOperation
Collection Hooks
beforeValidate
Transform data before validation runs:
hooks: {
beforeValidate: [
async ({ data, operation, req }) => {
if (operation === 'create') {
data.createdBy = req.user?.id
}
return data // Always return data
},
],
}
beforeChange
Transform data before database write (after validation):
hooks: {
beforeChange: [
async ({ data, operation, originalDoc, req }) => {
// Auto-generate slug on create
if (operation === 'create' && data.title) {
data.slug = data.title.toLowerCase().replace(/\s+/g, '-')
}
// Track last modified by
data.lastModifiedBy = req.user?.id
return data
},
],
}
afterChange
Side effects after database write:
hooks: {
afterChange: [
async ({ doc, operation, req, context }) => {
// Prevent infinite loops
if (context.skipAuditLog) return doc
// Create audit log entry
await req.payload.create({
collection: 'audit-logs',
data: {
action: operation,
collection: 'posts',
documentId: doc.id,
userId: req.user?.id,
timestamp: new Date(),
},
req, // CRITICAL: maintains transaction
context: { skipAuditLog: true },
})
return doc
},
],
}
beforeRead
Modify query before database read:
hooks: {
beforeRead: [
async ({ doc, req }) => {
// doc is the raw database document
// Can modify before afterRead transforms
return doc
},
],
}
afterRead
Transform data before sending to client:
hooks: {
afterRead: [
async ({ doc, req }) => {
// Add computed field
doc.fullName = `${doc.firstName} ${doc.lastName}`
// Hide sensitive data for non-admins
if (!req.user?.roles?.includes('admin')) {
delete doc.internalNotes
}
return doc
},
],
}
beforeDelete
Pre-delete validation or cleanup:
hooks: {
beforeDelete: [
async ({ id, req }) => {
// Cascading delete: remove related comments
await req.payload.delete({
collection: 'comments',
where: { post: { equals: id } },
req,
})
},
],
}
afterDelete
Post-delete cleanup:
hooks: {
afterDelete: [
async ({ doc, req }) => {
// Clean up uploaded files
if (doc.image) {
await deleteFile(doc.image.filename)
}
},
],
}
Field Hooks
Hooks on individual fields:
{
name: 'slug',
type: 'text',
hooks: {
beforeValidate: [
({ value, data }) => {
if (!value && data?.title) {
return data.title.toLowerCase().replace(/\s+/g, '-')
}
return value
},
],
afterRead: [
({ value }) => value?.toLowerCase(),
],
},
}
Context Pattern
Prevent infinite loops and share state between hooks:
hooks: {
afterChange: [
async ({ doc, req, context }) => {
// Check context flag to prevent loops
if (context.skipNotification) return doc
// Trigger related update with context flag
await req.payload.update({
collection: 'related',
id: doc.relatedId,
data: { updated: true },
req,
context: {
...context,
skipNotification: true, // Prevent loop
},
})
return doc
},
],
}
Transactions
CRITICAL: Always pass req for transaction integrity:
hooks: {
afterChange: [
async ({ doc, req }) => {
// ✅ Same transaction - atomic
await req.payload.create({
collection: 'audit-logs',
data: { documentId: doc.id },
req, // REQUIRED
})
// ❌ Separate transaction - can leave inconsistent state
await req.payload.create({
collection: 'audit-logs',
data: { documentId: doc.id },
// Missing req!
})
return doc
},
],
}
Next.js Revalidation with Context Control
import { revalidatePath, revalidateTag } from 'next/cache'
hooks: {
afterChange: [
async ({ doc, context }) => {
// Skip revalidation for internal updates
if (context.skipRevalidation) return doc
revalidatePath(`/posts/${doc.slug}`)
revalidateTag('posts')
return doc
},
],
}
Auth Hooks (Auth Collections Only)
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
hooks: {
afterLogin: [
async ({ doc, req }) => {
// Log login
await req.payload.create({
collection: 'login-logs',
data: { userId: doc.id, timestamp: new Date() },
req,
})
return doc
},
],
afterLogout: [
async ({ req }) => {
// Clear session data
},
],
afterMe: [
async ({ doc, req }) => {
// Add extra user info
return doc
},
],
afterRefresh: [
async ({ doc, req }) => {
// Custom token refresh logic
return doc
},
],
afterForgotPassword: [
async ({ args }) => {
// Custom forgot password notification
},
],
},
fields: [...],
}
Hook Arguments Reference
All hooks receive these base arguments:
| Argument | Description |
|---|---|
req |
Request object with payload, user, locale |
context |
Shared context object between hooks |
collection |
Collection config |
Operation-specific arguments:
| Hook | Additional Arguments |
|---|---|
beforeValidate |
data, operation, originalDoc |
beforeChange |
data, operation, originalDoc |
afterChange |
doc, operation, previousDoc |
beforeRead |
doc |
afterRead |
doc |
beforeDelete |
id |
afterDelete |
doc, id |
Best Practices
- Always return the data/doc - Even if unchanged
- Use context for loop prevention - Check before triggering recursive operations
- Pass req for transactions - Maintains atomicity
- Keep hooks focused - One responsibility per hook
- Use field hooks for field-specific logic - Better encapsulation
- Avoid heavy operations in beforeRead - Runs on every query
- Use afterChange for side effects - Email, webhooks, etc. -e
Access Control Reference
Overview
Access control functions determine WHO can do WHAT with documents:
type Access = (args: AccessArgs) => boolean | Where | Promise<boolean | Where>
Returns:
true- Full accessfalse- No accessWherequery - Filtered access (row-level security)
Collection-Level Access
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: isLoggedIn,
read: isPublishedOrAdmin,
update: isAdminOrAuthor,
delete: isAdmin,
},
fields: [...],
}
Common Patterns
Public Read, Admin Write
const isAdmin: Access = ({ req }) => {
return req.user?.roles?.includes('admin') ?? false
}
const isLoggedIn: Access = ({ req }) => {
return !!req.user
}
access: {
create: isLoggedIn,
read: () => true, // Public
update: isAdmin,
delete: isAdmin,
}
Row-Level Security (User's Own Documents)
const ownDocsOnly: Access = ({ req }) => {
if (!req.user) return false
// Admins see everything
if (req.user.roles?.includes('admin')) return true
// Others see only their own
return {
author: { equals: req.user.id },
}
}
access: {
read: ownDocsOnly,
update: ownDocsOnly,
delete: ownDocsOnly,
}
Complex Queries
const publishedOrOwn: Access = ({ req }) => {
// Not logged in: published only
if (!req.user) {
return { status: { equals: 'published' } }
}
// Admin: see all
if (req.user.roles?.includes('admin')) return true
// Others: published OR own drafts
return {
or: [
{ status: { equals: 'published' } },
{ author: { equals: req.user.id } },
],
}
}
Field-Level Access
Control access to specific fields:
{
name: 'internalNotes',
type: 'textarea',
access: {
read: ({ req }) => req.user?.roles?.includes('admin'),
update: ({ req }) => req.user?.roles?.includes('admin'),
},
}
Hide Field Completely
{
name: 'secretKey',
type: 'text',
access: {
read: () => false, // Never returned in API
update: ({ req }) => req.user?.roles?.includes('admin'),
},
}
Access Control Arguments
type AccessArgs = {
req: PayloadRequest
id?: string | number // Document ID (for update/delete)
data?: Record<string, unknown> // Incoming data (for create/update)
}
RBAC (Role-Based Access Control)
// Define roles
type Role = 'admin' | 'editor' | 'author' | 'subscriber'
// Helper functions
const hasRole = (req: PayloadRequest, role: Role): boolean => {
return req.user?.roles?.includes(role) ?? false
}
const hasAnyRole = (req: PayloadRequest, roles: Role[]): boolean => {
return roles.some(role => hasRole(req, role))
}
// Use in access control
const canEdit: Access = ({ req }) => {
return hasAnyRole(req, ['admin', 'editor'])
}
const canPublish: Access = ({ req }) => {
return hasAnyRole(req, ['admin', 'editor'])
}
const canDelete: Access = ({ req }) => {
return hasRole(req, 'admin')
}
Multi-Tenant Access
// Users belong to organizations
const sameOrgOnly: Access = ({ req }) => {
if (!req.user) return false
// Super admin sees all
if (req.user.roles?.includes('super-admin')) return true
// Others see only their org's data
return {
organization: { equals: req.user.organization },
}
}
// Apply to collection
access: {
create: ({ req }) => !!req.user,
read: sameOrgOnly,
update: sameOrgOnly,
delete: sameOrgOnly,
}
Global Access
For singleton documents:
export const Settings: GlobalConfig = {
slug: 'settings',
access: {
read: () => true,
update: ({ req }) => req.user?.roles?.includes('admin'),
},
fields: [...],
}
Important: Local API Access Control
Local API bypasses access control by default!
// ❌ SECURITY BUG: Access control bypassed
await payload.find({
collection: 'posts',
user: someUser,
})
// ✅ SECURE: Explicitly enforce access control
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // REQUIRED
})
Access Control with req.context
Share state between access checks and hooks:
const conditionalAccess: Access = ({ req }) => {
// Check context set by middleware or previous operation
if (req.context?.bypassAuth) return true
return req.user?.roles?.includes('admin')
}
Best Practices
- Default to restrictive - Start with
false, add permissions - Use query constraints for row-level - More efficient than filtering after
- Keep logic in reusable functions - DRY across collections
- Test with different user types - Admin, regular user, anonymous
- Remember Local API default - Always use
overrideAccess: falsefor user-facing operations - Document your access rules - Complex logic needs comments -e
Queries Reference
Local API
Find Multiple
const result = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
},
limit: 10,
page: 1,
sort: '-createdAt',
depth: 2,
})
// Result structure
{
docs: Post[],
totalDocs: number,
limit: number,
totalPages: number,
page: number,
pagingCounter: number,
hasPrevPage: boolean,
hasNextPage: boolean,
prevPage: number | null,
nextPage: number | null,
}
Find By ID
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 2,
})
Create
const newPost = await payload.create({
collection: 'posts',
data: {
title: 'New Post',
content: '...',
author: userId,
},
user: req.user, // For access control
})
Update
const updated = await payload.update({
collection: 'posts',
id: '123',
data: {
title: 'Updated Title',
},
})
Delete
const deleted = await payload.delete({
collection: 'posts',
id: '123',
})
Query Operators
Comparison
where: {
price: { equals: 100 },
price: { not_equals: 100 },
price: { greater_than: 100 },
price: { greater_than_equal: 100 },
price: { less_than: 100 },
price: { less_than_equal: 100 },
}
String Operations
where: {
title: { like: 'Hello' }, // Case-insensitive contains
title: { contains: 'world' }, // Case-sensitive contains
email: { exists: true }, // Field has value
}
Array Operations
where: {
tags: { in: ['tech', 'design'] }, // Value in array
tags: { not_in: ['spam'] }, // Value not in array
tags: { all: ['featured', 'popular'] }, // Has all values
}
AND/OR Logic
where: {
and: [
{ status: { equals: 'published' } },
{ author: { equals: userId } },
],
}
where: {
or: [
{ status: { equals: 'published' } },
{ author: { equals: userId } },
],
}
// Nested
where: {
and: [
{ status: { equals: 'published' } },
{
or: [
{ featured: { equals: true } },
{ 'author.roles': { in: ['admin'] } },
],
},
],
}
Nested Properties
Query through relationships:
where: {
'author.name': { contains: 'John' },
'category.slug': { equals: 'tech' },
}
Geospatial Queries
where: {
location: {
near: [-73.935242, 40.730610, 10000], // [lng, lat, maxDistanceMeters]
},
}
where: {
location: {
within: {
type: 'Polygon',
coordinates: [[[-74, 40], [-73, 40], [-73, 41], [-74, 41], [-74, 40]]],
},
},
}
Field Selection
Only fetch specific fields:
const posts = await payload.find({
collection: 'posts',
select: {
title: true,
slug: true,
author: true, // Will be populated based on depth
},
})
Depth (Relationship Population)
// depth: 0 - IDs only
{ author: '123' }
// depth: 1 - First level populated
{ author: { id: '123', name: 'John' } }
// depth: 2 (default) - Nested relationships populated
{ author: { id: '123', name: 'John', avatar: { url: '...' } } }
Pagination
// Page-based
await payload.find({
collection: 'posts',
page: 2,
limit: 20,
})
// Cursor-based (more efficient for large datasets)
await payload.find({
collection: 'posts',
where: {
createdAt: { greater_than: lastCursor },
},
limit: 20,
sort: 'createdAt',
})
Sorting
// Single field
sort: 'createdAt' // Ascending
sort: '-createdAt' // Descending
// Multiple fields
sort: ['-featured', '-createdAt']
Access Control in Local API
CRITICAL: Local API bypasses access control by default!
// ❌ INSECURE: Access control bypassed
await payload.find({
collection: 'posts',
user: someUser, // User is ignored!
})
// ✅ SECURE: Access control enforced
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // REQUIRED
})
REST API
Endpoints
GET /api/{collection} # Find
GET /api/{collection}/{id} # Find by ID
POST /api/{collection} # Create
PATCH /api/{collection}/{id} # Update
DELETE /api/{collection}/{id} # Delete
Query String
GET /api/posts?where[status][equals]=published&limit=10&sort=-createdAt&depth=2
Nested Queries
GET /api/posts?where[author.name][contains]=John
Complex Queries
GET /api/posts?where[or][0][status][equals]=published&where[or][1][author][equals]=123
GraphQL API
Query
query {
Posts(
where: { status: { equals: published } }
limit: 10
sort: "-createdAt"
) {
docs {
id
title
author {
name
}
}
totalDocs
}
}
Mutation
mutation {
createPost(data: { title: "New Post", status: draft }) {
id
title
}
}
Draft Queries
// Published only (default)
await payload.find({ collection: 'posts' })
// Include drafts
await payload.find({
collection: 'posts',
draft: true,
})
Count Only
const count = await payload.count({
collection: 'posts',
where: { status: { equals: 'published' } },
})
// Returns: { totalDocs: number }
Distinct Values
const categories = await payload.find({
collection: 'posts',
select: { category: true },
// Then dedupe in code
})
Performance Tips
- Use indexes - Add
index: trueto frequently queried fields - Limit depth - Lower depth = faster queries
- Select specific fields - Don't fetch what you don't need
- Use pagination - Never fetch all documents
- Avoid nested OR queries - Can be slow on large collections
- Use count for totals - Faster than fetching all docs -e
Advanced Features Reference
Jobs Queue
Background task processing:
Define Tasks
// payload.config.ts
export default buildConfig({
jobs: {
tasks: [
{
slug: 'sendEmail',
handler: async ({ payload, job }) => {
const { to, subject, body } = job.input
await sendEmail({ to, subject, body })
},
inputSchema: {
to: { type: 'text', required: true },
subject: { type: 'text', required: true },
body: { type: 'text', required: true },
},
},
{
slug: 'generateThumbnails',
handler: async ({ payload, job }) => {
const { mediaId } = job.input
// Process images...
},
},
],
},
})
Queue Jobs
// In a hook or endpoint
await payload.jobs.queue({
task: 'sendEmail',
input: {
to: 'user@example.com',
subject: 'Welcome!',
body: 'Thanks for signing up.',
},
})
Run Jobs
# In production, run job worker
payload jobs:run
Custom Endpoints
Collection Endpoints
export const Posts: CollectionConfig = {
slug: 'posts',
endpoints: [
{
path: '/publish/:id',
method: 'post',
handler: async (req) => {
const { id } = req.routeParams
const doc = await req.payload.update({
collection: 'posts',
id,
data: {
status: 'published',
publishedAt: new Date(),
},
req,
overrideAccess: false, // Respect permissions
})
return Response.json({ success: true, doc })
},
},
{
path: '/stats',
method: 'get',
handler: async (req) => {
const total = await req.payload.count({ collection: 'posts' })
const published = await req.payload.count({
collection: 'posts',
where: { status: { equals: 'published' } },
})
return Response.json({
total: total.totalDocs,
published: published.totalDocs,
})
},
},
],
}
Global Endpoints
// payload.config.ts
export default buildConfig({
endpoints: [
{
path: '/health',
method: 'get',
handler: async () => {
return Response.json({ status: 'ok' })
},
},
],
})
Plugins
Using Plugins
import { buildConfig } from 'payload'
import { seoPlugin } from '@payloadcms/plugin-seo'
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'
export default buildConfig({
plugins: [
seoPlugin({
collections: ['posts', 'pages'],
uploadsCollection: 'media',
}),
formBuilderPlugin({
fields: {
text: true,
email: true,
textarea: true,
},
}),
],
})
Creating Plugins
import type { Config, Plugin } from 'payload'
type MyPluginOptions = {
enabled?: boolean
collections?: string[]
}
export const myPlugin = (options: MyPluginOptions): Plugin => {
return (incomingConfig: Config): Config => {
const { enabled = true, collections = [] } = options
if (!enabled) return incomingConfig
return {
...incomingConfig,
collections: (incomingConfig.collections || []).map((collection) => {
if (!collections.includes(collection.slug)) return collection
return {
...collection,
fields: [
...collection.fields,
{
name: 'pluginField',
type: 'text',
admin: { position: 'sidebar' },
},
],
}
}),
}
}
}
Localization
Enable Localization
export default buildConfig({
localization: {
locales: [
{ label: 'English', code: 'en' },
{ label: 'Spanish', code: 'es' },
{ label: 'French', code: 'fr' },
],
defaultLocale: 'en',
fallback: true,
},
})
Localized Fields
{
name: 'title',
type: 'text',
localized: true, // Enable per-locale values
}
Query by Locale
// Local API
const posts = await payload.find({
collection: 'posts',
locale: 'es',
})
// REST API
GET /api/posts?locale=es
// Get all locales
const posts = await payload.find({
collection: 'posts',
locale: 'all',
})
Custom Components
Field Components
// components/CustomTextField.tsx
'use client'
import { useField } from '@payloadcms/ui'
export const CustomTextField: React.FC = () => {
const { value, setValue } = useField()
return (
<input
value={value || ''}
onChange={(e) => setValue(e.target.value)}
/>
)
}
// In field config
{
name: 'customField',
type: 'text',
admin: {
components: {
Field: '/components/CustomTextField',
},
},
}
Custom Views
// Add custom admin page
admin: {
components: {
views: {
Dashboard: '/components/CustomDashboard',
},
},
}
Authentication
Custom Auth Strategies
export const Users: CollectionConfig = {
slug: 'users',
auth: {
strategies: [
{
name: 'api-key',
authenticate: async ({ headers, payload }) => {
const apiKey = headers.get('x-api-key')
if (!apiKey) return { user: null }
const user = await payload.find({
collection: 'users',
where: { apiKey: { equals: apiKey } },
})
return { user: user.docs[0] || null }
},
},
],
},
}
Token Customization
auth: {
tokenExpiration: 7200, // 2 hours
cookies: {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
domain: process.env.COOKIE_DOMAIN,
},
}
Database Adapters
MongoDB
import { mongooseAdapter } from '@payloadcms/db-mongodb'
db: mongooseAdapter({
url: process.env.DATABASE_URL,
transactionOptions: {
maxCommitTimeMS: 30000,
},
})
PostgreSQL
import { postgresAdapter } from '@payloadcms/db-postgres'
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL,
},
})
Storage Adapters
S3
import { s3Storage } from '@payloadcms/storage-s3'
plugins: [
s3Storage({
collections: { media: true },
bucket: process.env.S3_BUCKET,
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
region: process.env.S3_REGION,
},
}),
]
Vercel Blob
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
plugins: [
vercelBlobStorage({
collections: { media: true },
token: process.env.BLOB_READ_WRITE_TOKEN,
}),
]
Email Adapters
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
email: nodemailerAdapter({
defaultFromAddress: 'noreply@example.com',
defaultFromName: 'My App',
transport: {
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
})