payload

Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors,…

INSTALLATION
npx skills add https://github.com/payloadcms/payload --skill payload
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Payload CMS Application Development

Payload is a Next.js native CMS with TypeScript-first architecture, providing admin panel, database management, REST/GraphQL APIs, authentication, and file storage.

Quick Reference

Task

Solution

Details

Auto-generate slugs

slugField()

FIELDS.md#slug-field-helper

Restrict content by user

Access control with query

ACCESS-CONTROL.md#row-level-security-with-complex-queries

Local API user ops

user + overrideAccess: false

QUERIES.md#access-control-in-local-api

Draft/publish workflow

versions: { drafts: true }

COLLECTIONS.md#versioning--drafts

Computed fields

virtual: true with field-level hooks.afterRead returning the value

FIELDS.md#virtual-fields

Conditional fields

admin.condition

FIELDS.md#conditional-fields

Custom field validation

validate function

FIELDS.md#validation

Filter relationship list

filterOptions on field

FIELDS.md#relationship

Select specific fields

select parameter

QUERIES.md#field-selection

Auto-set author/dates

beforeChange hook

HOOKS.md#collection-hooks

Prevent hook loops

req.context check

HOOKS.md#context

Cascading deletes

beforeDelete hook

HOOKS.md#collection-hooks

Geospatial queries

point field with near/within

FIELDS.md#point-geolocation

Reverse relationships

join field type

FIELDS.md#join-fields

Next.js revalidation

Context control in afterChange

HOOKS.md#nextjs-revalidation-with-context-control

Query by relationship

Nested property syntax

QUERIES.md#nested-properties

Complex queries

AND/OR logic

QUERIES.md#andor-logic

Transactions

Pass req to operations

ADAPTERS.md#threading-req-through-operations

Background jobs

Jobs queue with tasks

ADVANCED.md#jobs-queue

Custom API routes

Collection custom endpoints

ADVANCED.md#custom-endpoints

Cloud storage

Storage adapter plugins

ADAPTERS.md#storage-adapters

Multi-language

localization config + localized: true

ADVANCED.md#localization

Create plugin

(options) => (config) => Config

PLUGIN-DEVELOPMENT.md#plugin-architecture

Plugin package setup

Package structure with SWC

PLUGIN-DEVELOPMENT.md#plugin-package-structure

Add fields to collection

Map collections, spread fields

PLUGIN-DEVELOPMENT.md#adding-fields-to-collections

Plugin hooks

Preserve existing hooks in array

PLUGIN-DEVELOPMENT.md#adding-hooks

Check field type

Type guard functions

FIELD-TYPE-GUARDS.md

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'

import path from 'path'

import { fileURLToPath } from 'url'

const filename = fileURLToPath(import.meta.url)

const dirname = path.dirname(filename)

export default buildConfig({

  admin: {

    user: 'users',

    importMap: {

      baseDir: path.resolve(dirname),

    },

  },

  collections: [Users, Media],

  editor: lexicalEditor(),

  secret: process.env.PAYLOAD_SECRET,

  typescript: {

    outputFile: path.resolve(dirname, 'payload-types.ts'),

  },

  db: mongooseAdapter({

    url: process.env.DATABASE_URL,

  }),

})

Essential Patterns

Basic Collection

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' },

  ],

  timestamps: true,

}

For more collection patterns (auth, upload, drafts, live preview), see COLLECTIONS.md.

Common Fields

// Text field

{ name: 'title', type: 'text', required: true }

// Relationship

{ name: 'author', type: 'relationship', relationTo: 'users', required: true }

// Rich text

{ name: 'content', type: 'richText', required: true }

// Select

{ name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft' }

// Upload

{ name: 'image', type: 'upload', relationTo: 'media' }

For all field types (array, blocks, point, join, virtual, conditional, etc.), see FIELDS.md.

Hook Example

Hooks live at one of two levels and they are not interchangeable. Collection hooks receive { doc, data, req, operation, ... } and act on the whole document. Field hooks live inside an individual field's hooks object, receive { value, siblingData, ... }, and return the new value for that field. Computed/virtual fields, per-field formatters, and per-field access masking are field hooks; cross-field business logic is a collection hook.

// Collection-level: business logic across the document

export const Posts: CollectionConfig = {

  slug: 'posts',

  hooks: {

    beforeChange: [

      async ({ data, operation }) => {

        if (operation === 'create') {

          data.slug = slugify(data.title)

        }

        return data

      },

    ],

  },

  fields: [{ name: 'title', type: 'text' }],

}

// Field-level: compute / format a single field's value (virtual fields use this)

export const Users: CollectionConfig = {

  slug: 'users',

  fields: [

    { name: 'firstName', type: 'text' },

    { name: 'lastName', type: 'text' },

    {

      name: 'fullName',

      type: 'text',

      virtual: true,

      hooks: {

        afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`],

      },

    },

  ],

}

When asked to "compute a field" or "populate a field's value in a hook", use a field-level hook on that field — never a collection-level afterRead that mutates doc.

For all hook patterns, see HOOKS.md. For access control, see ACCESS-CONTROL.md.

Access Control with Type Safety

import type { Access } from 'payload'

import type { User } from '@/payload-types'

// Type-safe access control

export const adminOnly: Access = ({ req }) => {

  const user = req.user as User

  return user?.roles?.includes('admin') || false

}

// Row-level access control

export const ownPostsOnly: Access = ({ req }) => {

  const user = req.user as User

  if (!user) return false

  if (user.roles?.includes('admin')) return true

  return {

    author: { equals: user.id },

  }

}

Query Example

// Local API

const posts = await payload.find({

  collection: 'posts',

  where: {

    status: { equals: 'published' },

    'author.name': { contains: 'john' },

  },

  depth: 2,

  limit: 10,

  sort: '-createdAt',

})

// Query with populated relationships

const post = await payload.findByID({

  collection: 'posts',

  id: '123',

  depth: 2, // Populates relationships (default is 2)

})

// Returns: { author: { id: "user123", name: "John" } }

// Without depth, relationships return IDs only

const post = await payload.findByID({

  collection: 'posts',

  id: '123',

  depth: 0,

})

// Returns: { author: "user123" }

For all query operators and REST/GraphQL examples, see QUERIES.md.

Getting Payload Instance

// In API routes (Next.js)

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

import { getPayload } from 'payload'

import config from '@payload-config'

export default async function Page() {

  const payload = await getPayload({ config })

  const { docs } = await payload.find({ collection: 'posts' })

  return <div>{docs.map(post => <h1 key={post.id}>{post.title}</h1>)}</div>

}

Security Pitfalls

1. Local API Access Control (CRITICAL)

By default, Local API operations bypass ALL access control, even when passing a user.

// ❌ SECURITY BUG: Passes user but ignores their permissions

await payload.find({

  collection: 'posts',

  user: someUser, // Access control is BYPASSED!

})

// ✅ SECURE: Actually enforces the user's permissions

await payload.find({

  collection: 'posts',

  user: someUser,

  overrideAccess: false, // REQUIRED for access control

})

When to use each:

  • overrideAccess: true (default) - Server-side operations you trust (cron jobs, system tasks)
  • overrideAccess: false - When operating on behalf of a user (API routes, webhooks)

See QUERIES.md#access-control-in-local-api.

2. Transaction Failures in Hooks

**Nested operations in hooks without req break transaction atomicity.**

// ❌ DATA CORRUPTION RISK: Separate transaction

hooks: {

  afterChange: [

    async ({ doc, req }) => {

      await req.payload.create({

        collection: 'audit-log',

        data: { docId: doc.id },

        // Missing req - runs in separate transaction!

      })

    },

  ]

}

// ✅ ATOMIC: Same transaction

hooks: {

  afterChange: [

    async ({ doc, req }) => {

      await req.payload.create({

        collection: 'audit-log',

        data: { docId: doc.id },

        req, // Maintains atomicity

      })

    },

  ]

}

See ADAPTERS.md#threading-req-through-operations.

3. Infinite Hook Loops

Hooks triggering operations that trigger the same hooks 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: Use context flag

hooks: {

  afterChange: [

    async ({ doc, req, context }) => {

      if (context.skipHooks) return

      await req.payload.update({

        collection: 'posts',

        id: doc.id,

        data: { views: doc.views + 1 },

        context: { skipHooks: true },

        req,

      })

    },

  ]

}

See HOOKS.md#context.

Project Structure

src/

├── app/

│   ├── (frontend)/

│   │   └── page.tsx

│   └── (payload)/

│       └── admin/[[...segments]]/page.tsx

├── collections/

│   ├── Posts.ts

│   ├── Media.ts

│   └── Users.ts

├── globals/

│   └── Header.ts

├── components/

│   └── CustomField.tsx

├── hooks/

│   └── slugify.ts

└── payload.config.ts

Type Generation

// payload.config.ts

export default buildConfig({

  typescript: {

    outputFile: path.resolve(dirname, 'payload-types.ts'),

  },

  // ...

})

// Usage

import type { Post, User } from '@/payload-types'

Common Gotchas

  • Local API bypasses access control unless you pass overrideAccess: false
  • **Missing req in nested operations** breaks transaction atomicity
  • Hook loops — operations in hooks can re-trigger the same hooks; use req.context flags
  • Field-level access returns boolean only, no query constraints
  • Relationship depth defaults to 2; set depth: 0 for IDs only
  • Draft status_status field is auto-injected when drafts are enabled
  • Types are stale until you run generate:types
  • MongoDB transactions require replica set configuration
  • SQLite transactions are disabled by default; enable with transactionOptions: {}
  • Point fields are not supported in SQLite

Best Practices

Security

  • Default to restrictive access, gradually add permissions
  • Use overrideAccess: false when passing user to Local API
  • Field-level access only returns boolean (no query constraints)
  • Never trust client-provided data
  • Use saveToJWT: true for roles to avoid database lookups

Performance

  • Index frequently queried fields
  • Use select to limit returned fields
  • Set maxDepth on relationships to prevent over-fetching
  • Prefer query constraints over async operations in access control
  • Cache expensive operations in req.context

Data Integrity

  • Always pass req to nested operations in hooks
  • Use context flags to prevent infinite hook loops
  • Enable transactions for MongoDB (requires replica set) and Postgres
  • Use beforeValidate for data formatting
  • Use beforeChange for business logic

Type Safety

  • Run generate:types after schema changes
  • Import types from generated payload-types.ts
  • Type your user object: import type { User } from '@/payload-types'
  • Use field type guards for runtime type checking
  • When extracting any Payload value into a named constant — a collection, field, hook, access function, plugin, etc. — annotate it with the matching Payload type (CollectionConfig, Field, CollectionBeforeChangeHook, Access, Plugin, …) or use satisfies <Type>. Without an annotation, string properties like type: 'text' widen to string and discriminated unions (Field, CollectionConfig) fail to resolve. Inline literals get this for free via contextual typing; extracted constants do not.

Organization

  • Keep collections in separate files
  • Extract access control to access/ directory
  • Extract hooks to hooks/ directory
  • Use reusable field factories for common patterns
  • Document complex access control with comments

Reference Documentation

  • FIELDS.md - All field types, validation, admin options
  • COLLECTIONS.md - Collection configs, auth, upload, drafts, live preview
  • HOOKS.md - Collection hooks, field hooks, context patterns
  • QUERIES.md - Query operators, Local/REST/GraphQL APIs
  • ENDPOINTS.md - Custom API endpoints: authentication, helpers, request/response patterns
  • ADAPTERS.md - Database, storage, email adapters, transactions
  • ADVANCED.md - Authentication, jobs, endpoints, components, plugins, localization

Resources

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card