Files
website-enchun-mgr/.agent/skills/payload-cms/AGENTS.md
pkupuk ad8e2e313e chore(agent): configure AI agents and tools
Add configuration for BMad, Claude, OpenCode, and other AI agent tools and workflows.
2026-02-11 11:51:23 +08:00

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:

  1. Config Layer → Collections, globals, fields define your schema
  2. Hook Layer → Lifecycle events transform and validate data
  3. 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 req for transaction integrity
  • Recursive hooks use context flags
  • Types generated and imported from payload-types.ts
  • Access control functions are typed with Access type
  • Collections have meaningful admin.useAsTitle set

Reference Documentation

For detailed patterns, see:

Resources


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

  1. Always return the data/doc - Even if unchanged
  2. Use context for loop prevention - Check before triggering recursive operations
  3. Pass req for transactions - Maintains atomicity
  4. Keep hooks focused - One responsibility per hook
  5. Use field hooks for field-specific logic - Better encapsulation
  6. Avoid heavy operations in beforeRead - Runs on every query
  7. 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 access
  • false - No access
  • Where query - 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

  1. Default to restrictive - Start with false, add permissions
  2. Use query constraints for row-level - More efficient than filtering after
  3. Keep logic in reusable functions - DRY across collections
  4. Test with different user types - Admin, regular user, anonymous
  5. Remember Local API default - Always use overrideAccess: false for user-facing operations
  6. 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

  1. Use indexes - Add index: true to frequently queried fields
  2. Limit depth - Lower depth = faster queries
  3. Select specific fields - Don't fetch what you don't need
  4. Use pagination - Never fetch all documents
  5. Avoid nested OR queries - Can be slow on large collections
  6. 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,
    },
  },
})