payload
GitHub针对 Payload CMS 项目的开发助手,涵盖配置、集合、字段、钩子及访问控制等核心功能。支持调试验证错误、安全问题和事务处理,提供快速参考与最佳实践。
Trigger Scenarios
Install
npx skills add payloadcms/payload --skill payload -g -y
SKILL.md
Frontmatter
{
"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
| Task | Solution | Details |
|---|---|---|
| Auto-generate slugs | { type: 'slug', useAsSlug: 'title' } |
FIELDS.md#slug-field |
| 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 |
| Document titles | Stored top-level field in admin.useAsTitle |
COLLECTIONS.md#useastitle |
| 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_statusfield (draft/published/changed) — don't add your ownstatusfield, it's redundant. Only skip versions for collections that have no publish/draft lifecycle (e.g. internal join tables, settings). - Use the native
slugfield type for all slugs instead of hand-rolling{ name: 'slug', type: 'text', unique: true }. It auto-generates the slug from a source field, adds a regenerate toggle, and defaults torequired,unique,index, andposition: 'sidebar'.useAsSlugis required — name the source field to generate from:{ name: 'slug', type: 'slug', useAsSlug: 'title' }. 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.- Use a stored top-level field for
admin.useAsTitle. Never setadmin.useAsTitleto a computed field configured withvirtual: true; these fields are not queryable and Payload rejects the configuration. A relationship-path virtual such asvirtual: 'author.name'is supported, but use it only when the title must come from a related document.
Basic Collection
import type { CollectionConfig } 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 },
{ name: 'slug', type: 'slug', useAsSlug: 'title' }, // 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 native field type instead of a hand-rolled text field
{ name: 'slug', type: 'slug', useAsSlug: 'title' }
// 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.autoGeneratedefaults totrue, so the dev server regenerates types automatically whenever your config changes. Don't rungenerate:typesmanually while the dev server is running — it's redundant. - During builds:
payload buildgenerates the import map and types before runningnext build. Prefer it over callingnext builddirectly so neither is ever stale. Pass--no-typesto 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 runpayload 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
reqin nested operations breaks transaction atomicity - Hook loops — operations in hooks can re-trigger the same hooks; use
req.contextflags - Field-level access returns boolean only, no query constraints
- Relationship depth defaults to 2; set
depth: 0for IDs only - Draft status —
_statusfield is auto-injected when drafts are enabled - Types regenerate automatically in dev (
autoGenerate) and duringpayload build— avoid runninggenerate:typesmanually - MongoDB transactions require replica set configuration
- SQLite transactions are disabled by default; enable with
transactionOptions: {} - Point fields are not supported in SQLite
- Computed virtual titles — fields configured with
virtual: truecannot be used inadmin.useAsTitle; use a stored top-level field
Best Practices
Content Modeling
- Enable
versions: { drafts: true }by default on content collections; rely on the auto-injected_statusfield rather than adding a customstatusfield - Use the native
slugfield type for slugs instead of hand-rolling a unique text field - Use a stored top-level field for
admin.useAsTitle; never use a computedvirtual: truefield as the title - 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: falsewhen passinguserto Local API - Field-level access only returns boolean (no query constraints)
- Never trust client-provided data
- Use
saveToJWT: truefor roles to avoid database lookups
Performance
- Index frequently queried fields
- Use
selectto limit returned fields - Set
maxDepthon relationships to prevent over-fetching - Prefer query constraints over async operations in access control
- Cache expensive operations in
req.context
Data Integrity
- Always pass
reqto nested operations in hooks - Use context flags to prevent infinite hook loops
- Enable transactions for MongoDB (requires replica set) and Postgres
- Use
beforeValidatefor data formatting - Use
beforeChangefor business logic
Type Safety
- Let dev (
autoGenerate) andpayload buildgenerate types; rungenerate:typesmanually 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 usesatisfies <Type>. Without an annotation, string properties liketype: 'text'widen tostringand 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/templates
Version History
- f7df213 Current 2026-08-20 07:29


