518 lines
22 KiB
Markdown
518 lines
22 KiB
Markdown
---
|
|
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 | `slugField()` | [FIELDS.md#slug-field-helper](reference/FIELDS.md#slug-field-helper) |
|
|
| Restrict content by user | Access control with query | [ACCESS-CONTROL.md#row-level-security-with-complex-queries](reference/ACCESS-CONTROL.md#row-level-security-with-complex-queries) |
|
|
| Local API user ops | `user` + `overrideAccess: false` | [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api) |
|
|
| Draft/publish workflow | `versions: { drafts: true }` | [COLLECTIONS.md#versioning--drafts](reference/COLLECTIONS.md#versioning--drafts) |
|
|
| Computed fields | `virtual: true` with **field-level** `hooks.afterRead` returning the value | [FIELDS.md#virtual-fields](reference/FIELDS.md#virtual-fields) |
|
|
| Conditional fields | `admin.condition` | [FIELDS.md#conditional-fields](reference/FIELDS.md#conditional-fields) |
|
|
| Custom field validation | `validate` function | [FIELDS.md#validation](reference/FIELDS.md#validation) |
|
|
| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) |
|
|
| Select specific fields | `select` parameter | [QUERIES.md#field-selection](reference/QUERIES.md#field-selection) |
|
|
| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
|
|
| Prevent hook loops | `req.context` check | [HOOKS.md#context](reference/HOOKS.md#context) |
|
|
| Cascading deletes | beforeDelete hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
|
|
| Geospatial queries | `point` field with `near`/`within` | [FIELDS.md#point-geolocation](reference/FIELDS.md#point-geolocation) |
|
|
| Reverse relationships | `join` field type | [FIELDS.md#join-fields](reference/FIELDS.md#join-fields) |
|
|
| Next.js revalidation | Context control in afterChange | [HOOKS.md#nextjs-revalidation-with-context-control](reference/HOOKS.md#nextjs-revalidation-with-context-control) |
|
|
| Query by relationship | Nested property syntax | [QUERIES.md#nested-properties](reference/QUERIES.md#nested-properties) |
|
|
| Complex queries | AND/OR logic | [QUERIES.md#andor-logic](reference/QUERIES.md#andor-logic) |
|
|
| Transactions | Pass `req` to operations | [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations) |
|
|
| Background jobs | Jobs queue with tasks | [ADVANCED.md#jobs-queue](reference/ADVANCED.md#jobs-queue) |
|
|
| Custom API routes | Collection custom endpoints | [ADVANCED.md#custom-endpoints](reference/ADVANCED.md#custom-endpoints) |
|
|
| Cloud storage | Storage adapter plugins | [ADAPTERS.md#storage-adapters](reference/ADAPTERS.md#storage-adapters) |
|
|
| Multi-language | `localization` config + `localized: true` | [ADVANCED.md#localization](reference/ADVANCED.md#localization) |
|
|
| Create plugin | `(options) => (config) => Config` | [PLUGIN-DEVELOPMENT.md#plugin-architecture](reference/PLUGIN-DEVELOPMENT.md#plugin-architecture) |
|
|
| Plugin package setup | Package structure with SWC | [PLUGIN-DEVELOPMENT.md#plugin-package-structure](reference/PLUGIN-DEVELOPMENT.md#plugin-package-structure) |
|
|
| Add fields to collection | Map collections, spread fields | [PLUGIN-DEVELOPMENT.md#adding-fields-to-collections](reference/PLUGIN-DEVELOPMENT.md#adding-fields-to-collections) |
|
|
| Plugin hooks | Preserve existing hooks in array | [PLUGIN-DEVELOPMENT.md#adding-hooks](reference/PLUGIN-DEVELOPMENT.md#adding-hooks) |
|
|
| Check field type | Type guard functions | [FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md) |
|
|
|
|
## Quick Start
|
|
|
|
```bash
|
|
npx create-payload-app@latest my-app
|
|
cd my-app
|
|
pnpm dev
|
|
```
|
|
|
|
### Minimal Config
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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](reference/COLLECTIONS.md).
|
|
|
|
### Common Fields
|
|
|
|
```ts
|
|
// 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](reference/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.
|
|
|
|
```ts
|
|
// 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](reference/HOOKS.md). For access control, see [ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md).
|
|
|
|
### Access Control with Type Safety
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
// 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](reference/QUERIES.md).
|
|
|
|
### Getting Payload Instance
|
|
|
|
```ts
|
|
// 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.
|
|
|
|
```ts
|
|
// ❌ 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](reference/QUERIES.md#access-control-in-local-api).
|
|
|
|
### 2. Transaction Failures in Hooks
|
|
|
|
**Nested operations in hooks without `req` break transaction atomicity.**
|
|
|
|
```ts
|
|
// ❌ 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](reference/ADAPTERS.md#threading-req-through-operations).
|
|
|
|
### 3. Infinite Hook Loops
|
|
|
|
**Hooks triggering operations that trigger the same hooks create infinite loops.**
|
|
|
|
```ts
|
|
// ❌ 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](reference/HOOKS.md#context).
|
|
|
|
## Project Structure
|
|
|
|
```txt
|
|
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`).
|
|
|
|
```ts
|
|
// 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
|
|
|
|
1. **Local API bypasses access control** unless you pass `overrideAccess: false`
|
|
2. **Missing `req` in nested operations** breaks transaction atomicity
|
|
3. **Hook loops** — operations in hooks can re-trigger the same hooks; use `req.context` flags
|
|
4. **Field-level access** returns boolean only, no query constraints
|
|
5. **Relationship depth** defaults to 2; set `depth: 0` for IDs only
|
|
6. **Draft status** — `_status` field is auto-injected when drafts are enabled
|
|
7. **Types regenerate automatically** in dev (`autoGenerate`) and during `payload build` — avoid running `generate:types` manually
|
|
8. **MongoDB transactions** require replica set configuration
|
|
9. **SQLite transactions** are disabled by default; enable with `transactionOptions: {}`
|
|
10. **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](reference/FIELDS.md)** - All field types, validation, admin options
|
|
- **[FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md)** - Type guards for runtime field type checking and narrowing
|
|
- **[COLLECTIONS.md](reference/COLLECTIONS.md)** - Collection configs, auth, upload, drafts, live preview
|
|
- **[HOOKS.md](reference/HOOKS.md)** - Collection hooks, field hooks, context patterns
|
|
- **[ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md)** - Collection, field, global access control, RBAC, multi-tenant
|
|
- **[ACCESS-CONTROL-ADVANCED.md](reference/ACCESS-CONTROL-ADVANCED.md)** - Context-aware, time-based, subscription-based access, factory functions, templates
|
|
- **[QUERIES.md](reference/QUERIES.md)** - Query operators, Local/REST/GraphQL APIs
|
|
- **[ENDPOINTS.md](reference/ENDPOINTS.md)** - Custom API endpoints: authentication, helpers, request/response patterns
|
|
- **[ADAPTERS.md](reference/ADAPTERS.md)** - Database, storage, email adapters, transactions
|
|
- **[ADVANCED.md](reference/ADVANCED.md)** - Authentication, jobs, endpoints, components, plugins, localization
|
|
- **[PLUGIN-DEVELOPMENT.md](reference/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>
|