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.
- 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.
- 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
{p.title}
)}Common Field Types // Text
// Relationship
// Rich text
// Select
// Upload
// 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:
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