back
loading skill details...
Use when working with Payload projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors,…
Payload 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
Defaults & Conventions
Apply these defaults when modeling content unless there's a clear reason not to:
Enable drafts/versions by default: versions: { drafts: true }. This is the
recommended starting point for any content collection. It auto-injects a
_status field (draft / published / changed) — don't add your own
status field, it's redundant. Only skip versions for collections that have
no publish/draft lifecycle (e.g. internal join tables, settings).
Use slugField() for all slugs instead of hand-rolling
{ name: 'slug', type: 'text', unique: true }. It auto-generates the slug from
the title, adds a regenerate toggle, and handles uniqueness/indexing for you.
It defaults to generating from a title field — if the collection has no
title, pass the source field: slugField({ useAsSlug: 'name' }).
position: 'sidebar' is for short, at-a-glance fields — status, category,
author, publish date. Avoid it for long fields that need horizontal space to be
usable (description, rich text content, long text). Those belong in the main
document area.
Basic Collection
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
// _status (from versions.drafts) shows the draft/published state — no custom status field needed
defaultColumns: ['title', 'author', '_status', 'createdAt'],
},
versions: {
drafts: true,
},
fields: [
{ name: 'title', type: 'text', required: true },
slugField(), // auto-generates from `title`, unique + indexed, sidebar position
{ name: 'content', type: 'richText' }, // long field — stays in the main area, not the sidebar
// short, at-a-glance field — good sidebar candidate
{ name: 'author', type: 'relationship', relationTo: 'users', admin: { position: 'sidebar' } },
],
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 }
// Slug — use the helper instead of a hand-rolled text field
slugField()
// Select (for genuine taxonomy — NOT publish state; use versions.drafts + _status for that)
{ name: 'category', type: 'select', options: ['news', 'tutorial', 'opinion'] }
// 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
Building & Type Generation
Payload generates payload-types.ts for you — you rarely need to run generate:types by hand.
During development: typescript.autoGenerate defaults to true, so the dev
server regenerates types automatically whenever your config changes. Don't run
generate:types manually while the dev server is running — it's redundant.
During builds: payload build generates the import map and types before
running next build. Prefer it over calling next build directly so neither is
ever stale. Pass --no-types to skip type generation.
Manual generation (payload generate:types) is an escape hatch — only when
neither the dev server nor a build is in the loop (e.g. a one-off script, or CI
before a step that doesn't run payload build).
// payload.config.ts
export default buildConfig({
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
// autoGenerate defaults to true — types regenerate in dev automatically
},
})
// 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 regenerate automatically in dev (autoGenerate) and during payload build — avoid running generate:types manually
MongoDB transactions require replica set configuration
SQLite transactions are disabled by default; enable with transactionOptions: {}
Point fields are not supported in SQLite
Best Practices
Content Modeling
Enable versions: { drafts: true } by default on content collections; rely on the
auto-injected _status field rather than adding a custom status field
Use slugField() for slugs instead of hand-rolling a unique text field
Reserve position: 'sidebar' for short, at-a-glance fields (status, category,
author, date); keep long fields (description, rich text) in the main area
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
Let dev (autoGenerate) and payload build generate types; run generate:types manually only when neither is running
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
FIELD-TYPE-GUARDS.md - Type guards for runtime field type checking and narrowing
COLLECTIONS.md - Collection configs, auth, upload, drafts, live preview
HOOKS.md - Collection hooks, field hooks, context patterns
ACCESS-CONTROL.md - Collection, field, global access control, RBAC, multi-tenant
ACCESS-CONTROL-ADVANCED.md - Context-aware, time-based, subscription-based access, factory functions, templates
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
PLUGIN-DEVELOPMENT.md - Plugin architecture, monorepo structure, patterns, best practices
Resources
llms-full.txt: https://payloadcms.com/llms-full.txt
Docs: https://payloadcms.com/docs
GitHub: https://github.com/payloadcms/payload
Examples: https://github.com/payloadcms/payload/tree/main/examples
Templates: https://github.com/payloadcms/payload/tree/main/templatesdon't have the plugin yet? install it then click "run inline in claude" again.