| name | payload |
| description | Use when working with Payload projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior. |
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
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',
defaultColumns: ['title', 'author', '_status', 'createdAt'],
},
versions: {
drafts: true,
},
fields: [
{ name: 'title', type: 'text', required: true },
slugField(),
{ name: 'content', type: 'richText' },
{ 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
{ name: 'title', type: 'text', required: true }
{ name: 'author', type: 'relationship', relationTo: 'users', required: true }
{ name: 'content', type: 'richText', required: true }
slugField()
{ name: 'category', type: 'select', options: ['news', 'tutorial', 'opinion'] }
{ 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.
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' }],
}
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'
export const adminOnly: Access = ({ req }) => {
const user = req.user as User
return user?.roles?.includes('admin') || false
}
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
const posts = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
'author.name': { contains: 'john' },
},
depth: 2,
limit: 10,
sort: '-createdAt',
})
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 2,
})
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 0,
})
For all query operators and REST/GraphQL examples, see QUERIES.md.
Getting Payload Instance
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)
}
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.
await payload.find({
collection: 'posts',
user: someUser,
})
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false,
})
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.
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
})
},
]
}
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
req,
})
},
]
}
See ADAPTERS.md#threading-req-through-operations.
3. Infinite Hook Loops
Hooks triggering operations that trigger the same hooks create infinite loops.
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { views: doc.views + 1 },
req,
})
},
]
}
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).
export default buildConfig({
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
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