关于 payload
面向开发者的 Payload 应用开发技能,涵盖 CMS 配置、查询、钩子、访问控制与插件模式,适用于 Next.js 和 TypeScript 项目。
主要功能
- 01集合与字段建模
- 02草稿、版本与 slug
- 03查询与访问控制
- 04钩子与事务指南
- 05插件和 API 模式
适用场景
- 01配置内容集合
- 02实现按角色控制内容访问
- 03添加钩子与后台任务
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ payloadcms/ payload --skill payload安装后在 Claude Code 中输入/payload确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
reference/ACCESS-CONTROL-ADVANCED.mdACCESS-CONTROL.mdADAPTERS.mdADVANCED.mdCOLLECTIONS.mdENDPOINTS.mdFIELD-TYPE-GUARDS.mdFIELDS.mdHOOKS.mdPLUGIN-DEVELOPMENT.mdQUERIES.md详细介绍
Payload 应用开发
Payload 是一个 Next.js 原生 CMS,采用 TypeScript 优先的架构,提供管理面板、数据库管理、REST/GraphQL API、身份验证和文件存储。
快速参考
快速开始
npx create-payload-app@latest my-app
cd my-app
pnpm dev
最小配置
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,
}),
})
核心模式
默认值与约定
在对内容建模时,除非有明确的理由不这样做,否则请应用以下默认值:
- 默认启用草稿/版本:
versions: { drafts: true }。对于任何内容集合,这都是 推荐的起点。它会自动注入一个_status字段(draft/published/changed)——不要添加你自己的status字段,那是多余的。只有对没有发布/草稿生命周期的集合(例如内部连接表、设置)才跳过版本功能。 - 对所有 slug 使用原生的
slug字段类型,而不要手动拼凑{ name: 'slug', type: 'text', unique: true }。它会根据源字段自动生成 slug, 添加一个重新生成开关,并默认设置为required、unique、index和position: 'sidebar'。useAsSlug是必填的——指定用于生成的 源字段:{ name: 'slug', type: 'slug', useAsSlug: 'title' }。 position: 'sidebar'用于简短的、一目了然的字段——状态、分类、 作者、发布日期。避免将其用于需要水平空间才能 使用的长字段(描述、富文本内容、长文本)。那些字段应放在主 文档区域。- 对
admin.useAsTitle使用存储的顶层字段。 绝不要将admin.useAsTitle设置为配置了virtual: true的计算字段;这些 字段不可查询,Payload 会拒绝该配置。支持像virtual: 'author.name'这样的 关系路径虚拟字段,但仅当标题必须来自 关联文档时才使用它。
基础集合
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,
}
有关更多集合模式(认证、上传、草稿、实时预览),请参见 COLLECTIONS.md。
常用字段
// 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' }
有关所有字段类型(array、blocks、point、join、virtual、conditional 等),请参见 FIELDS.md。
Hook 示例
Hook 位于两个层级之一,它们不能互换。集合 Hook 接收 { doc, data, req, operation, ... } 并对整个文档进行操作。字段 Hook 位于单个字段的 hooks 对象内,接收 { value, siblingData, ... },并返回该字段的新值。计算/虚拟字段、逐字段格式化器和逐字段访问掩码都是字段 Hook;跨字段业务逻辑则是集合 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}`],
},
},
],
}
当被要求“计算一个字段”或“在 Hook 中填充字段的值”时,请对该字段使用字段级 Hook——绝不要使用会修改 doc 的集合级 afterRead。
有关所有 Hook 模式,请参见 HOOKS.md。有关访问控制,请参见 ACCESS-CONTROL.md。
带类型安全的访问控制
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 },
}
}
查询示例
// Local API
const posts = await payload.find({
collection: 'posts',
overrideAccess: true,
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',
overrideAccess: true,
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',
overrideAccess: true,
id: '123',
depth: 0,
})
// Returns: { author: "user123" }
有关所有查询运算符以及 REST/GraphQL 示例,请参见 QUERIES.md。
获取 Payload 实例
// In API routes (Next.js)
import { getPayload } from 'payload'
import config from '@payload-config'
export async function GET(request: Request) {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: request.headers })
const posts = await payload.find({
collection: 'posts',
overrideAccess: false, // this route answers for whoever called it
user,
})
return Response.json(posts)
}
// In Server Components
import { headers } from 'next/headers'
import { getPayload } from 'payload'
import config from '@payload-config'
export default async function Page() {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: await headers() })
const { docs } = await payload.find({
collection: 'posts',
overrideAccess: false,
user,
})
return <div>{docs.map(post => <h1 key={post.id}>{post.title}</h1>)}</div>
}
安全陷阱
1. 本地 API 访问控制(严重)
overrideAccess: true 会绕过所有访问控制,即使传入了用户也是如此。
// ❌ SECURITY BUG: Passes user but bypasses their permissions anyway
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: true, // Access control is BYPASSED!
})
// ✅ SECURE: Respects the user's permissions — this is the default, overrideAccess can be omitted
await payload.find({
collection: 'posts',
user: someUser,
})
在 overrideAccess 为可选的 Local API 操作中,它默认为 false——除非被显式绕过,否则访问控制都会生效。
- 省略它,或设置
overrideAccess: false- 代表用户进行操作(API 路由、面向用户的服务器函数,以及以用户身份运行的 webhook)。同时传入user。 overrideAccess: true- 受信任的系统工作(cron 任务、种子数据、迁移、系统任务,以及被有意授予完整权限的独立认证 webhook)
绝不要出于习惯或照搬附近的调用来设置 overrideAccess: true——选择此次调用真正需要的值。
请参见 QUERIES.md#access-control-in-local-api。
2. Hook 中的事务失败
在 Hook 中不使用 req 的嵌套操作会破坏事务原子性。
// ❌ DATA CORRUPTION RISK: Separate transaction
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
overrideAccess: true,
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',
overrideAccess: true,
data: { docId: doc.id },
req, // Maintains atomicity
})
},
]
}
请参见 ADAPTERS.md#threading-req-through-operations。
3. 无限 Hook 循环
Hook 触发操作、操作又触发同一批 Hook,会形成无限循环。
// ❌ INFINITE LOOP
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.update({
collection: 'posts',
overrideAccess: true,
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',
overrideAccess: true,
id: doc.id,
data: { views: doc.views + 1 },
context: { skipHooks: true },
req,
})
},
]
}
参见 HOOKS.md#context。
项目结构
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
构建与类型生成
Payload 会为你生成 payload-types.ts —— 你很少需要手动运行 generate:types。
- 开发期间:
typescript.autoGenerate默认为true,因此只要你的配置发生变化, 开发服务器就会自动重新生成类型。不要在开发服务器运行时手动运行generate:types—— 那是多余的。 - 构建期间:
payload build会在运行next build之前生成 import map 和类型。优先使用它而不是直接调用next build,这样两者都 不会过期。传入--no-types可跳过类型生成。 - 手动生成(
payload generate:types)是一种应急手段 —— 仅当开发服务器和构建都不在 流程中时使用(例如一次性脚本,或 CI 中某个不运行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'
常见坑
- 带可选
overrideAccess的 Local API 操作默认遵循访问控制 —— 仅在受信任的服务端工作中将其设为true - 嵌套操作中缺少
req会破坏事务原子性 - Hook 循环 —— Hook 中的操作可能再次触发同一批 Hook;请使用
req.context标志 - 字段级访问 仅返回布尔值,不支持查询约束
- 关系深度 默认为 2;设为
depth: 0则只返回 ID - 草稿状态 —— 启用草稿时会自动注入
_status字段 - 类型会自动重新生成,在开发环境(
autoGenerate)和payload build期间都是如此 —— 避免手动运行generate:types - MongoDB 事务 需要副本集配置
- SQLite 事务 默认禁用;可通过
transactionOptions: {}启用 - Point 字段 在 SQLite 中不受支持
- 计算型虚拟标题 —— 配置为
virtual: true的字段不能用于admin.useAsTitle;请使用存储的顶层字段
最佳实践
内容建模
- 在内容集合上默认启用
versions: { drafts: true };依赖 自动注入的_status字段,而不是添加自定义的status字段 - 使用原生
slug字段类型来存储 slug,而不是手工打造一个唯一文本字段 - 为
admin.useAsTitle使用存储的顶层字段;绝不要使用计算型的virtual: true字段作为标题 - 将
position: 'sidebar'留给简短、一目了然的字段(状态、分类、 作者、日期);将长字段(描述、富文本)保留在主区域
安全性
- 默认采用限制性访问,逐步添加权限
- 只要调用是代表某个用户执行的,就使用
overrideAccess: false,并传入该user - 字段级访问仅返回布尔值(不支持查询约束)
- 绝不要信任客户端提供的数据
- 对角色使用
saveToJWT: true以避免数据库查询
性能
- 为频繁查询的字段建立索引
- 使用
select限制返回的字段 - 在关系上设置
maxDepth以防止过度获取 - 在访问控制中优先使用查询约束而非异步操作
- 在
req.context中缓存开销大的操作
数据完整性
- 始终将
req传给 Hook 中的嵌套操作 - 使用 context 标志防止无限 Hook 循环
- 为 MongoDB(需要副本集)和 Postgres 启用事务
- 使用
beforeValidate进行数据格式化 - 使用
beforeChange处理业务逻辑
类型安全
- 让 dev(
autoGenerate)和payload build生成类型;仅当两者都未运行时,才手动运行generate:types - 从生成的
payload-types.ts导入类型 - 为你的 user 对象标注类型:
import type { User } from '@/payload-types' - 使用字段类型守卫进行运行时类型检查
- 将任何 Payload 值提取为具名常量时——无论是 collection、field、hook、access 函数、plugin 等——都要用匹配的 Payload 类型(
CollectionConfig、Field、CollectionBeforeChangeHook、Access、Plugin……)进行标注,或使用satisfies <Type>。没有标注时,诸如type: 'text'这样的字符串属性会被拓宽为string,可辨识联合(Field、CollectionConfig)就无法解析。内联字面量通过上下文类型推断自动获得这一效果;提取出的常量则不会。
组织方式
- 将 collections 放在单独的文件中
- 将访问控制提取到
access/目录 - 将 hooks 提取到
hooks/目录 - 对常见模式使用可复用的字段工厂
- 用注释记录复杂的访问控制
参考文档
- FIELDS.md - 所有字段类型、校验、管理选项
- FIELD-TYPE-GUARDS.md - 用于运行时字段类型检查和收窄的类型守卫
- COLLECTIONS.md - Collection 配置、认证、上传、草稿、实时预览
- HOOKS.md - Collection hooks、field hooks、context 模式
- ACCESS-CONTROL.md - Collection、field、global 访问控制、RBAC、多租户
- ACCESS-CONTROL-ADVANCED.md - 上下文感知、基于时间、基于订阅的访问控制、工厂函数、模板
- QUERIES.md - 查询操作符、Local/REST/GraphQL API
- ENDPOINTS.md - 自定义 API 端点:认证、辅助函数、请求/响应模式
- ADAPTERS.md - 数据库、存储、邮件适配器、事务
- ADVANCED.md - 认证、任务、端点、组件、插件、本地化
- PLUGIN-DEVELOPMENT.md - 插件架构、monorepo 结构、模式、最佳实践