Merge pull request #5 from payloadcms/feat/update-payload-skill

feat(payload): sync skill with latest
This commit is contained in:
Jessica Rynkar
2026-07-07 17:55:47 +01:00
committed by GitHub
7 changed files with 230 additions and 93 deletions
+4 -1
View File
@@ -16,7 +16,7 @@ The `payload` skill provides expert guidance on:
## Usage
Once installed, the Agent will automatically invoke the skill when you're working on Payload CMS projects. The skill activates when you:
Once installed, the Agent will automatically invoke the skill when you're working on Payload projects. The skill activates when you:
- Edit `payload.config.ts` files
- Work with collection or global configurations
@@ -36,12 +36,15 @@ skills/payload/
├── SKILL.md # Main skill file with quick reference
└── reference/
├── FIELDS.md # All field types and configurations
├── FIELD-TYPE-GUARDS.md # Type guards for field discrimination
├── COLLECTIONS.md # Collection patterns
├── HOOKS.md # Hook patterns and examples
├── ACCESS-CONTROL.md # Basic access control
├── ACCESS-CONTROL-ADVANCED.md # Advanced access patterns
├── QUERIES.md # Query patterns and APIs
├── ENDPOINTS.md # Custom endpoints
├── ADAPTERS.md # Database and storage adapters
├── PLUGIN-DEVELOPMENT.md # Plugin development patterns
└── ADVANCED.md # Jobs, endpoints, localization
```
+162 -53
View File
@@ -9,35 +9,35 @@ Payload is a Next.js native CMS with TypeScript-first architecture, providing ad
## 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 afterRead | [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#text-field](reference/FIELDS.md#text-field) |
| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) |
| Select specific fields | `select` parameter | [QUERIES.md#local-api](reference/QUERIES.md#local-api) |
| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
| Prevent hook loops | `req.context` check | [HOOKS.md#hook-context](reference/HOOKS.md#hook-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) |
| 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
@@ -80,22 +80,47 @@ export default buildConfig({
## 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',
defaultColumns: ['title', 'author', 'status', 'createdAt'],
// _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: 'text', unique: true, index: true },
{ name: 'content', type: 'richText' },
{ name: 'author', type: 'relationship', relationTo: 'users' },
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,
}
@@ -115,8 +140,11 @@ For more collection patterns (auth, upload, drafts, live preview), see [COLLECTI
// Rich text
{ name: 'content', type: 'richText', required: true }
// Select
{ name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft' }
// 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' }
@@ -126,7 +154,10 @@ For all field types (array, blocks, point, join, virtual, conditional, etc.), se
### 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: {
@@ -141,8 +172,27 @@ export const Posts: CollectionConfig = {
},
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
@@ -232,22 +282,6 @@ export default async function Page() {
}
```
### Logger Usage
```ts
// ✅ Valid: single string
payload.logger.error('Something went wrong')
// ✅ Valid: object with msg and err
payload.logger.error({ msg: 'Failed to process', err: error })
// ❌ Invalid: don't pass error as second argument
payload.logger.error('Failed to process', error)
// ❌ Invalid: use `err` not `error`, use `msg` not `message`
payload.logger.error({ message: 'Failed', error: error })
```
## Security Pitfalls
### 1. Local API Access Control (CRITICAL)
@@ -371,21 +405,96 @@ src/
└── payload.config.ts
```
## Type Generation
## 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
@@ -85,7 +85,6 @@ export const Posts: CollectionConfig = {
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'status', type: 'select', options: ['draft', 'published'] },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
}
+1 -1
View File
@@ -236,7 +236,7 @@ export const CustomField: TextFieldClientComponent = () => {
```tsx
'use client'
import { DefaultTemplate } from '@payloadcms/next/templates'
import { DefaultTemplate } from '@payloadcms/ui/rsc'
export const CustomView = () => {
return (
+15 -18
View File
@@ -6,6 +6,7 @@ Complete reference for collection configurations and patterns.
```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
@@ -15,11 +16,16 @@ export const Posts: CollectionConfig = {
},
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'createdAt'],
// _status comes from versions.drafts below — no custom status field needed
defaultColumns: ['title', 'author', '_status', 'createdAt'],
group: 'Content', // Organize in admin sidebar
description: 'Blog posts and articles',
listSearchableFields: ['title', 'slug'],
},
// Enable drafts by default — auto-injects the _status field (draft/published/changed)
versions: {
drafts: true,
},
fields: [
{
name: 'title',
@@ -27,25 +33,18 @@ export const Posts: CollectionConfig = {
required: true,
index: true,
},
{
name: 'slug',
type: 'text',
unique: true,
index: true,
admin: { position: 'sidebar' },
},
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
defaultValue: 'draft',
},
slugField(), // unique + indexed, sidebar position — don't hand-roll a slug text field
],
defaultSort: '-createdAt',
timestamps: true,
}
```
> Don't add a custom `status` select for publish state — enabling
> `versions: { drafts: true }` injects a managed `_status` field
> (`draft` / `published` / `changed`) that the admin UI and Draft Preview already
> understand. Use it in `defaultColumns` and access control directly.
## Auth Collection
```ts
@@ -129,6 +128,7 @@ Enable real-time content preview during editing.
```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'
const generatePreviewPath = ({
slug,
@@ -164,10 +164,7 @@ export const Pages: CollectionConfig = {
req,
}),
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'slug', type: 'text' },
],
fields: [{ name: 'title', type: 'text' }, slugField()],
}
```
+45 -16
View File
@@ -26,9 +26,19 @@ const textField: TextField = {
}
```
> **When to use `position: 'sidebar'`:** Reserve the sidebar for short fields that
> give quick insight into the content — status, category, author, publish date,
> slug. Avoid it for fields that need horizontal space to be useful, like a
> description, rich text content, or long text — those belong in the main document
> area. (`title` above is shown in the sidebar only to demonstrate the option.)
### Slug Field Helper
Built-in helper for auto-generating slugs:
Built-in helper for auto-generating slugs. **Use this for all slugs** instead of
hand-rolling a `{ name: 'slug', type: 'text', unique: true }` field — it
auto-generates the slug from the title, adds a regenerate toggle, and handles
uniqueness and indexing. Call it with no args when the collection has a `title`
field — the slug is generated from `title` by default:
```ts
import { slugField } from 'payload'
@@ -38,21 +48,37 @@ export const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{ name: 'title', type: 'text', required: true },
slugField({
name: 'slug', // defaults to 'slug'
useAsSlug: 'title', // defaults to 'title'
checkboxName: 'generateSlug', // defaults to 'generateSlug'
localized: true,
required: true,
overrides: (defaultField) => {
// Customize the generated fields if needed
return defaultField
},
}),
slugField(), // name: 'slug', useAsSlug: 'title', required, unique, position: 'sidebar'
],
}
```
`useAsSlug` defaults to `'title'`, so if the collection has **no `title` field**,
you must pass the source field explicitly — otherwise the slug generates from a
field that doesn't exist:
```ts
// Collection keyed on `name` instead of `title`
fields: [{ name: 'name', type: 'text', required: true }, slugField({ useAsSlug: 'name' })]
```
Override defaults when needed (`overrides` receives the generated `RowField`):
```ts
slugField({
name: 'slug', // defaults to 'slug'
useAsSlug: 'title', // defaults to 'title'
checkboxName: 'generateSlug', // defaults to 'generateSlug'
localized: true,
required: true, // defaults to true
position: 'sidebar', // default; the slug is a short field well-suited to the sidebar
overrides: (field) => {
field.fields[1].label = 'Custom Slug Label'
return field
},
})
```
## Rich Text (Lexical)
```ts
@@ -264,14 +290,17 @@ const blocksField: BlocksField = {
```ts
import type { SelectField } from 'payload'
// Use select for genuine taxonomy. For publish state, enable versions.drafts
// and rely on the auto-injected _status field instead of a custom select.
const selectField: SelectField = {
name: 'status',
name: 'priority',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
],
defaultValue: 'draft',
defaultValue: 'medium',
required: true,
}
@@ -147,15 +147,15 @@ plugin-<name>/
"@swc/cli": "^0.1.62",
"@swc/core": "^1.3.0",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"cross-env": "10.1.0",
"eslint": "^9.0.0",
"next": "^15.4.10",
"payload": "^3.0.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"rimraf": "^5.0.0",
"typescript": "^5.0.0",
"vitest": "^3.0.0"
"typescript": "^6.0.0",
"vitest": "4.1.6"
},
"peerDependencies": {
"payload": "^3.0.0"