payload-cms by connorads/dotfiles
npx skills add https://github.com/connorads/dotfiles --skill payload-cmsPayload 是一个基于 Next.js 的原生 CMS,采用 TypeScript 优先架构。此技能传授构建集合、钩子、访问控制和查询的正确方式的专业知识。
将 Payload 视为三个相互关联的层:
每个操作都遵循以下流程:配置 → 访问检查 → 钩子链 → 数据库 → 响应钩子
| 任务 | 解决方案 | 详情 |
|---|---|---|
| 自动生成 slug | slugField() 或 beforeChange 钩子 | [references/fields.md#slug-field] |
| 按用户限制 | 带有查询约束的访问控制 | [references/access-control.md] |
| 带认证的本地 API | user + |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
overrideAccess: false| [references/queries.md#local-api] |
| 草稿/发布 | versions: { drafts: true } | [references/collections.md#drafts] |
| 计算字段 | virtual: true 配合 afterRead 钩子 | [references/fields.md#virtual] |
| 条件字段 | admin.condition | [references/fields.md#conditional] |
| 筛选关联关系 | 字段上的 filterOptions | [references/fields.md#relationship] |
| 防止钩子循环 | req.context 标志 | [references/hooks.md#context] |
| 事务 | 将 req 传递给所有操作 | [references/hooks.md#transactions] |
| 后台作业 | 带任务的作业队列 | [references/advanced.md#jobs] |
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'
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 }),
})
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,
}
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 }],
}
import type { Access } from 'payload'
// 类型安全:仅管理员访问
export const adminOnly: Access = ({ req }) => {
return req.user?.roles?.includes('admin') ?? false
}
// 行级:用户只能看到自己的帖子
export const ownPostsOnly: Access = ({ req }) => {
if (!req.user) return false
if (req.user.roles?.includes('admin')) return true
return { author: { equals: req.user.id } }
}
// 带有访问控制的本地 API
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, // 关键:强制执行权限
})
默认行为会绕过所有访问控制。 这是头号安全错误。
// ❌ 安全漏洞:即使有用户,访问控制也被绕过
await payload.find({ collection: 'posts', user: someUser })
// ✅ 安全:明确强制执行权限
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // 必需
})
规则: 任何代表用户执行的操作都要使用 overrideAccess: false。
没有 req 的操作会在单独的事务中运行。
// ❌ 数据损坏:单独的事务
hooks: {
afterChange: [async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
// 缺少 req - 破坏了原子性!
})
}]
}
// ✅ 原子性:同一事务
hooks: {
afterChange: [async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
req, // 保持事务
})
}]
}
规则: 在钩子中始终将 req 传递给嵌套操作。
触发自身的钩子会创建无限循环。
// ❌ 无限循环
hooks: {
afterChange: [async ({ doc, req }) => {
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { views: doc.views + 1 },
req,
}) // 再次触发 afterChange!
}]
}
// ✅ 安全:上下文标志打破循环
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 },
})
}]
}
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.ts
在模式更改后生成类型:
// payload.config.ts
export default buildConfig({
typescript: { outputFile: 'payload-types.ts' },
})
// 使用
import type { Post, User } from '@/payload-types'
// 在 API 路由中
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)
}
// 在服务器组件中
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>
}
// 文本
{ name: 'title', type: 'text', required: true }
// 关联关系
{ name: 'author', type: 'relationship', relationTo: 'users' }
// 富文本
{ name: 'content', type: 'richText' }
// 选择
{ name: 'status', type: 'select', options: ['draft', 'published'] }
// 上传
{ name: 'image', type: 'upload', relationTo: 'media' }
// 数组
{
name: 'tags',
type: 'array',
fields: [{ name: 'tag', type: 'text' }],
}
// 区块(多态内容)
{
name: 'layout',
type: 'blocks',
blocks: [HeroBlock, ContentBlock, CTABlock],
}
在选择方法时:
| 场景 | 方法 |
|---|---|
| 保存前数据转换 | beforeChange 钩子 |
| 读取后数据转换 | afterRead 钩子 |
| 执行业务规则 | 访问控制函数 |
| 复杂验证 | 字段上的 validate 函数 |
| 计算显示值 | 带有 afterRead 的虚拟字段 |
| 相关文档列表 | join 字段类型 |
| 副作用(邮件、Webhook) | 带有上下文保护的 afterChange 钩子 |
| 数据库级约束 | 带有 unique: true 或 index: true 的字段 |
良好的 Payload 代码:
overrideAccess: falsereq 以保证事务完整性context 标志payload-types.ts 导入Access 类型进行类型定义admin.useAsTitle详细模式请参阅:
每周安装量
115
代码库
GitHub 星标数
8
首次出现
2026年1月21日
安全审计
已安装于
gemini-cli92
opencode91
claude-code87
codex86
antigravity84
cursor83
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.
Think of Payload as three interconnected layers :
Every operation flows through: Config → Access Check → Hook Chain → Database → Response Hooks
| 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] |
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'
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 }),
})
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,
}
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 }],
}
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 } }
}
// 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
})
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.
Operations withoutreq 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.
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 },
})
}]
}
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.ts
Generate types after schema changes:
// payload.config.ts
export default buildConfig({
typescript: { outputFile: 'payload-types.ts' },
})
// Usage
import type { Post, User } from '@/payload-types'
// 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>
}
// 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],
}
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) | hook with context guard |
Good Payload code:
overrideAccess: falsereq for transaction integritycontext flagspayload-types.tsAccess typeadmin.useAsTitle setFor detailed patterns, see:
Weekly Installs
115
Repository
GitHub Stars
8
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
gemini-cli92
opencode91
claude-code87
codex86
antigravity84
cursor83
新闻稿撰写工具:使用 inference.sh CLI 进行事实核查与专业新闻稿创作
7,500 周安装
afterChange| Database-level constraint | Field with unique: true or index: true |