
Payload Cms
- 241 installs
- 15 repo stars
- Updated August 1, 2026
- connorads/dotfiles
payload-cms is a Claude Code skill that scaffolds and extends Payload CMS projects—collections, fields, access control, hooks, and REST/GraphQL APIs—for developers building headless content backends in TypeScript/Node.
About
payload-cms is a backend skill from connorads/dotfiles for developers working with Payload CMS in TypeScript and Node.js. It guides scaffolding new projects and extending collections, field schemas, access control rules, lifecycle hooks, and REST or GraphQL APIs for headless content backends. Use it when standing up a CMS layer for marketing sites, product docs, or multi-tenant content apps without building admin CRUD from scratch. The skill fits Claude Code or Cursor sessions where the next step is defining Payload config, auth gates, or API exposure rather than generic Express routes. Reach for payload-cms when the stack already chose Payload and the task is model design plus API wiring.
- Collection and field schema design
- Access control and auth configuration
- Lifecycle hooks and custom endpoints
- REST and GraphQL API usage
- Admin UI customization patterns
Payload Cms by the numbers
- 241 all-time installs (skills.sh)
- Ranked #1,538 of 4,348 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/connorads/dotfiles --skill payload-cmsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 241 |
|---|---|
| repo stars | ★ 15 |
| Last updated | August 1, 2026 |
| Repository | connorads/dotfiles ↗ |
How do you scaffold Payload CMS in TypeScript?
Scaffold and extend Payload CMS projects—collections, fields, access control, hooks, and REST/GraphQL APIs—for headless content backends in TypeScript/Node apps.
Who is it for?
TypeScript/Node developers adding or extending a Payload CMS headless content backend with collections and API access control.
Skip if: Teams using a different CMS stack or developers who only need static site content without a Node admin backend.
When should I use this skill?
User scaffolds Payload CMS, defines collections/fields, configures access control, hooks, or REST/GraphQL APIs
What you get
Payload config files, collection schemas, access rules, hooks, and REST/GraphQL API endpoints
- Payload collection configs
- access control rules
- REST/GraphQL API endpoints
Files
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 devMinimal 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.tsType 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](references/fields.md) - All field types, validation, conditional logic
- [references/collections.md](references/collections.md) - Auth, uploads, drafts, live preview
- [references/hooks.md](references/hooks.md) - Hook lifecycle, context, patterns
- [references/access-control.md](references/access-control.md) - RBAC, row-level, field-level
- [references/queries.md](references/queries.md) - Operators, Local/REST/GraphQL APIs
- [references/advanced.md](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
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 devMinimal 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.tsType 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](references/fields.md) - All field types, validation, conditional logic
- [references/collections.md](references/collections.md) - Auth, uploads, drafts, live preview
- [references/hooks.md](references/hooks.md) - Hook lifecycle, context, patterns
- [references/access-control.md](references/access-control.md) - RBAC, row-level, field-level
- [references/queries.md](references/queries.md) - Operators, Local/REST/GraphQL APIs
- [references/advanced.md](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 → afterOperationCollection 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 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
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} # DeleteQuery String
GET /api/posts?where[status][equals]=published&limit=10&sort=-createdAt&depth=2Nested Queries
GET /api/posts?where[author.name][contains]=JohnComplex Queries
GET /api/posts?where[or][0][status][equals]=published&where[or][1][author][equals]=123GraphQL 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:runCustom 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,
},
},
})Payload CMS Agent Skill
React and Next.js CMS development guidelines optimized for AI agents and LLMs.
Overview
This skill transfers expert knowledge for building Payload CMS applications. It covers:
- Collections & Fields - Schema design, field types, validation
- Hooks - Lifecycle events, data transformation, side effects
- Access Control - RBAC, row-level security, field-level permissions
- Queries - Local API, REST, GraphQL with operators
- Advanced - Jobs, plugins, localization, custom components
Installation
Claude Code / OpenCode
npx add-skill https://github.com/connorads/dotfiles/tree/master/.claude/skills/payload-cmsClaude.ai
Upload the payload-cms.zip file to Claude's Skills settings.
Cursor
Copy SKILL.md content to .cursor/rules/payload-cms.mdc
Manual
Place the skill folder in your agent's skills directory:
.claude/skills/payload-cms/
├── SKILL.md
├── AGENTS.md
└── references/
├── fields.md
├── collections.md
├── hooks.md
├── access-control.md
├── queries.md
└── advanced.mdUsage
The skill activates when working on tasks involving:
payload.config.tsconfiguration- Collection or global definitions
- Field configurations
- Hooks and lifecycle events
- Access control functions
- Database queries (Local API, REST, GraphQL)
- Custom endpoints
- Authentication setup
- File uploads
- Drafts and versioning
- Plugin development
Examples
Ask your AI agent:
- "Create a Posts collection with draft/publish workflow"
- "Add access control so users only see their own documents"
- "Fix the infinite loop in my afterChange hook"
- "Query posts by nested author relationship"
- "Set up row-level multi-tenant access"
Structure
| File | Purpose |
|---|---|
SKILL.md | Core instructions loaded on activation |
AGENTS.md | Full compiled document for single-file agents |
references/ | Detailed docs loaded on demand |
Key Security Patterns
The skill emphasizes three critical patterns:
1. Local API Access Control - Always use overrideAccess: false when acting on behalf of users 2. Transaction Integrity - Pass req to all nested operations in hooks 3. Infinite Loop Prevention - Use context flags for recursive operations
Resources
Credits
Based on the official Payload CMS cursor rules and documentation, adapted to the Vercel Agent Skills format.
License
MIT
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
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
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:runCustom 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,
},
},
})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,
}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: [...],
}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 → afterOperationCollection 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.
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} # DeleteQuery String
GET /api/posts?where[status][equals]=published&limit=10&sort=-createdAt&depth=2Nested Queries
GET /api/posts?where[author.name][contains]=JohnComplex Queries
GET /api/posts?where[or][0][status][equals]=published&where[or][1][author][equals]=123GraphQL 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
Related skills
FAQ
What does the payload-cms skill configure?
payload-cms configures Payload CMS collections, fields, access control, hooks, and REST/GraphQL APIs in TypeScript/Node projects. Developers use it to stand up or extend headless content backends without hand-rolling admin CRUD.
Which stack does payload-cms assume?
payload-cms assumes Payload CMS on TypeScript and Node.js for headless content apps. The skill focuses on backend schema and API wiring rather than frontend frameworks or deployment automation.