Files
xevion.dev/apps/payload/AGENTS.md
Xevion af81d8e048 refactor: large refactor around monorepo
Just a commit point while I'm testing stuff. Already decided at this
point to simplify and revert away from PayloadCMS.
2026-01-04 13:18:34 -06:00

1142 lines
26 KiB
Markdown

# Payload CMS Development Rules
You are an expert Payload CMS developer. When working with Payload projects, follow these rules:
## Core Principles
1. **TypeScript-First**: Always use TypeScript with proper types from Payload
2. **Security-Critical**: Follow all security patterns, especially access control
3. **Type Generation**: Run `generate:types` script after schema changes
4. **Transaction Safety**: Always pass `req` to nested operations in hooks
5. **Access Control**: Understand Local API bypasses access control by default
6. **Access Control**: Ensure roles exist when modifiyng collection or globals with access controls
### Code Validation
- To validate typescript correctness after modifying code run `tsc --noEmit`
- Generate import maps after creating or modifying components.
## Project Structure
```
src/
├── app/
│ ├── (frontend)/ # Frontend routes
│ └── (payload)/ # Payload admin routes
├── collections/ # Collection configs
├── globals/ # Global configs
├── components/ # Custom React components
├── hooks/ # Hook functions
├── access/ # Access control functions
└── payload.config.ts # Main config
```
## Configuration
### Minimal Config Pattern
```typescript
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,
}),
})
```
## Collections
### Basic Collection
```typescript
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' },
],
timestamps: true,
}
```
### Auth Collection with RBAC
```typescript
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
defaultValue: ['user'],
required: true,
saveToJWT: true, // Include in JWT for fast access checks
access: {
update: ({ req: { user } }) => user?.roles?.includes('admin'),
},
},
],
}
```
## Fields
### Common Patterns
```typescript
// Auto-generate slugs
import { slugField } from 'payload'
slugField({ fieldToUse: 'title' })
// Relationship with filtering
{
name: 'category',
type: 'relationship',
relationTo: 'categories',
filterOptions: { active: { equals: true } },
}
// Conditional field
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
admin: {
condition: (data) => data.featured === true,
},
}
// Virtual field
{
name: 'fullName',
type: 'text',
virtual: true,
hooks: {
afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`],
},
}
```
## CRITICAL SECURITY PATTERNS
### 1. Local API Access Control (MOST IMPORTANT)
```typescript
// ❌ SECURITY BUG: Access control bypassed
await payload.find({
collection: 'posts',
user: someUser, // Ignored! Operation runs with ADMIN privileges
})
// ✅ SECURE: Enforces user permissions
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // REQUIRED
})
// ✅ Administrative operation (intentional bypass)
await payload.find({
collection: 'posts',
// No user, overrideAccess defaults to true
})
```
**Rule**: When passing `user` to Local API, ALWAYS set `overrideAccess: false`
### 2. Transaction Safety in Hooks
```typescript
// ❌ 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
})
},
],
}
```
**Rule**: ALWAYS pass `req` to nested operations in hooks
### 3. Prevent Infinite Hook Loops
```typescript
// ❌ 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,
})
},
],
}
```
## Access Control
### Collection-Level Access
```typescript
import type { Access } from 'payload'
// Boolean return
const authenticated: Access = ({ req: { user } }) => Boolean(user)
// Query constraint (row-level security)
const ownPostsOnly: Access = ({ req: { user } }) => {
if (!user) return false
if (user?.roles?.includes('admin')) return true
return {
author: { equals: user.id },
}
}
// Async access check
const projectMemberAccess: Access = async ({ req, id }) => {
const { user, payload } = req
if (!user) return false
if (user.roles?.includes('admin')) return true
const project = await payload.findByID({
collection: 'projects',
id: id as string,
depth: 0,
})
return project.members?.includes(user.id)
}
```
### Field-Level Access
```typescript
// Field access ONLY returns boolean (no query constraints)
{
name: 'salary',
type: 'number',
access: {
read: ({ req: { user }, doc }) => {
// Self can read own salary
if (user?.id === doc?.id) return true
// Admin can read all
return user?.roles?.includes('admin')
},
update: ({ req: { user } }) => {
// Only admins can update
return user?.roles?.includes('admin')
},
},
}
```
### Common Access Patterns
```typescript
// Anyone
export const anyone: Access = () => true
// Authenticated only
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
// Admin only
export const adminOnly: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin')
}
// Admin or self
export const adminOrSelf: Access = ({ req: { user } }) => {
if (user?.roles?.includes('admin')) return true
return { id: { equals: user?.id } }
}
// Published or authenticated
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
if (user) return true
return { _status: { equals: 'published' } }
}
```
## Hooks
### Common Hook Patterns
```typescript
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
// Before validation - format data
beforeValidate: [
async ({ data, operation }) => {
if (operation === 'create') {
data.slug = slugify(data.title)
}
return data
},
],
// Before save - business logic
beforeChange: [
async ({ data, req, operation, originalDoc }) => {
if (operation === 'update' && data.status === 'published') {
data.publishedAt = new Date()
}
return data
},
],
// After save - side effects
afterChange: [
async ({ doc, req, operation, previousDoc, context }) => {
// Check context to prevent loops
if (context.skipNotification) return
if (operation === 'create') {
await sendNotification(doc)
}
return doc
},
],
// After read - computed fields
afterRead: [
async ({ doc, req }) => {
doc.viewCount = await getViewCount(doc.id)
return doc
},
],
// Before delete - cascading deletes
beforeDelete: [
async ({ req, id }) => {
await req.payload.delete({
collection: 'comments',
where: { post: { equals: id } },
req, // Important for transaction
})
},
],
},
}
```
## Queries
### Local API
```typescript
// Find with complex query
const posts = await payload.find({
collection: 'posts',
where: {
and: [{ status: { equals: 'published' } }, { 'author.name': { contains: 'john' } }],
},
depth: 2, // Populate relationships
limit: 10,
sort: '-createdAt',
select: {
title: true,
author: true,
},
})
// Find by ID
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 2,
})
// Create
const newPost = await payload.create({
collection: 'posts',
data: {
title: 'New Post',
status: 'draft',
},
})
// Update
await payload.update({
collection: 'posts',
id: '123',
data: { status: 'published' },
})
// Delete
await payload.delete({
collection: 'posts',
id: '123',
})
```
### Query Operators
```typescript
// Equals
{ status: { equals: 'published' } }
// Not equals
{ status: { not_equals: 'draft' } }
// Greater than / less than
{ price: { greater_than: 100 } }
{ age: { less_than_equal: 65 } }
// Contains (case-insensitive)
{ title: { contains: 'payload' } }
// Like (all words present)
{ description: { like: 'cms headless' } }
// In array
{ category: { in: ['tech', 'news'] } }
// Exists
{ image: { exists: true } }
// Near (geospatial)
{ location: { near: [-122.4194, 37.7749, 10000] } }
```
### AND/OR Logic
```typescript
{
or: [
{ status: { equals: 'published' } },
{ author: { equals: user.id } },
],
}
{
and: [
{ status: { equals: 'published' } },
{ featured: { equals: true } },
],
}
```
## Getting Payload Instance
```typescript
// 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>
}
```
## Components
The Admin Panel can be extensively customized using React Components. Custom Components can be Server Components (default) or Client Components.
### Defining Components
Components are defined using **file paths** (not direct imports) in your config:
**Component Path Rules:**
- Paths are relative to project root or `config.admin.importMap.baseDir`
- Named exports: use `#ExportName` suffix or `exportName` property
- Default exports: no suffix needed
- File extensions can be omitted
```typescript
import { buildConfig } from 'payload'
export default buildConfig({
admin: {
components: {
// Logo and branding
graphics: {
Logo: '/components/Logo',
Icon: '/components/Icon',
},
// Navigation
Nav: '/components/CustomNav',
beforeNavLinks: ['/components/CustomNavItem'],
afterNavLinks: ['/components/NavFooter'],
// Header
header: ['/components/AnnouncementBanner'],
actions: ['/components/ClearCache', '/components/Preview'],
// Dashboard
beforeDashboard: ['/components/WelcomeMessage'],
afterDashboard: ['/components/Analytics'],
// Auth
beforeLogin: ['/components/SSOButtons'],
logout: { Button: '/components/LogoutButton' },
// Settings
settingsMenu: ['/components/SettingsMenu'],
// Views
views: {
dashboard: { Component: '/components/CustomDashboard' },
},
},
},
})
```
**Component Path Rules:**
- Paths are relative to project root or `config.admin.importMap.baseDir`
- Named exports: use `#ExportName` suffix or `exportName` property
- Default exports: no suffix needed
- File extensions can be omitted
### Component Types
1. **Root Components** - Global Admin Panel (logo, nav, header)
2. **Collection Components** - Collection-specific (edit view, list view)
3. **Global Components** - Global document views
4. **Field Components** - Custom field UI and cells
### Component Types
1. **Root Components** - Global Admin Panel (logo, nav, header)
2. **Collection Components** - Collection-specific (edit view, list view)
3. **Global Components** - Global document views
4. **Field Components** - Custom field UI and cells
### Server vs Client Components
**All components are Server Components by default** (can use Local API directly):
```tsx
// Server Component (default)
import type { Payload } from 'payload'
async function MyServerComponent({ payload }: { payload: Payload }) {
const posts = await payload.find({ collection: 'posts' })
return <div>{posts.totalDocs} posts</div>
}
export default MyServerComponent
```
**Client Components** need the `'use client'` directive:
```tsx
'use client'
import { useState } from 'react'
import { useAuth } from '@payloadcms/ui'
export function MyClientComponent() {
const [count, setCount] = useState(0)
const { user } = useAuth()
return (
<button onClick={() => setCount(count + 1)}>
{user?.email}: Clicked {count} times
</button>
)
}
```
### Using Hooks (Client Components Only)
```tsx
'use client'
import {
useAuth, // Current user
useConfig, // Payload config (client-safe)
useDocumentInfo, // Document info (id, collection, etc.)
useField, // Field value and setter
useForm, // Form state
useFormFields, // Multiple field values (optimized)
useLocale, // Current locale
useTranslation, // i18n translations
usePayload, // Local API methods
} from '@payloadcms/ui'
export function MyComponent() {
const { user } = useAuth()
const { config } = useConfig()
const { id, collection } = useDocumentInfo()
const locale = useLocale()
const { t } = useTranslation()
return <div>Hello {user?.email}</div>
}
```
### Collection/Global Components
```typescript
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
components: {
// Edit view
edit: {
PreviewButton: '/components/PostPreview',
SaveButton: '/components/CustomSave',
SaveDraftButton: '/components/SaveDraft',
PublishButton: '/components/Publish',
},
// List view
list: {
Header: '/components/ListHeader',
beforeList: ['/components/BulkActions'],
afterList: ['/components/ListFooter'],
},
},
},
}
```
### Field Components
```typescript
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
admin: {
components: {
// Edit view field
Field: '/components/StatusField',
// List view cell
Cell: '/components/StatusCell',
// Field label
Label: '/components/StatusLabel',
// Field description
Description: '/components/StatusDescription',
// Error message
Error: '/components/StatusError',
},
},
}
```
**UI Field** (presentational only, no data):
```typescript
{
name: 'refundButton',
type: 'ui',
admin: {
components: {
Field: '/components/RefundButton',
},
},
}
```
### Performance Best Practices
1. **Import correctly:**
- Admin Panel: `import { Button } from '@payloadcms/ui'`
- Frontend: `import { Button } from '@payloadcms/ui/elements/Button'`
2. **Optimize re-renders:**
```tsx
// ❌ BAD: Re-renders on every form change
const { fields } = useForm()
// ✅ GOOD: Only re-renders when specific field changes
const value = useFormFields(([fields]) => fields[path])
```
3. **Prefer Server Components** - Only use Client Components when you need:
- State (useState, useReducer)
- Effects (useEffect)
- Event handlers (onClick, onChange)
- Browser APIs (localStorage, window)
4. **Minimize serialized props** - Server Components serialize props sent to client
### Styling Components
```tsx
import './styles.scss'
export function MyComponent() {
return <div className="my-component">Content</div>
}
```
```scss
// Use Payload's CSS variables
.my-component {
background-color: var(--theme-elevation-500);
color: var(--theme-text);
padding: var(--base);
border-radius: var(--border-radius-m);
}
// Import Payload's SCSS library
@import '~@payloadcms/ui/scss';
.my-component {
@include mid-break {
background-color: var(--theme-elevation-900);
}
}
```
### Type Safety
```tsx
import type {
TextFieldServerComponent,
TextFieldClientComponent,
TextFieldCellComponent,
SelectFieldServerComponent,
// ... etc
} from 'payload'
export const MyField: TextFieldClientComponent = (props) => {
// Fully typed props
}
```
### Import Map
Payload auto-generates `app/(payload)/admin/importMap.js` to resolve component paths.
**Regenerate manually:**
```bash
payload generate:importmap
```
**Set custom location:**
```typescript
export default buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname, 'src'),
importMapFile: path.resolve(dirname, 'app', 'custom-import-map.js'),
},
},
})
```
## Custom Endpoints
```typescript
import type { Endpoint } from 'payload'
import { APIError } from 'payload'
// Always check authentication
export const protectedEndpoint: Endpoint = {
path: '/protected',
method: 'get',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
// Use req.payload for database operations
const data = await req.payload.find({
collection: 'posts',
where: { author: { equals: req.user.id } },
})
return Response.json(data)
},
}
// Route parameters
export const trackingEndpoint: Endpoint = {
path: '/:id/tracking',
method: 'get',
handler: async (req) => {
const { id } = req.routeParams
const tracking = await getTrackingInfo(id)
if (!tracking) {
return Response.json({ error: 'not found' }, { status: 404 })
}
return Response.json(tracking)
},
}
```
## Drafts & Versions
```typescript
export const Pages: CollectionConfig = {
slug: 'pages',
versions: {
drafts: {
autosave: true,
schedulePublish: true,
validate: false, // Don't validate drafts
},
maxPerDoc: 100,
},
access: {
read: ({ req: { user } }) => {
// Public sees only published
if (!user) return { _status: { equals: 'published' } }
// Authenticated sees all
return true
},
},
}
// Create draft
await payload.create({
collection: 'pages',
data: { title: 'Draft Page' },
draft: true, // Skips required field validation
})
// Read with drafts
const page = await payload.findByID({
collection: 'pages',
id: '123',
draft: true, // Returns draft if available
})
```
## Field Type Guards
```typescript
import {
fieldAffectsData,
fieldHasSubFields,
fieldIsArrayType,
fieldIsBlockType,
fieldSupportsMany,
fieldHasMaxDepth,
} from 'payload'
function processField(field: Field) {
// Check if field stores data
if (fieldAffectsData(field)) {
console.log(field.name) // Safe to access
}
// Check if field has nested fields
if (fieldHasSubFields(field)) {
field.fields.forEach(processField) // Safe to access
}
// Check field type
if (fieldIsArrayType(field)) {
console.log(field.minRows, field.maxRows)
}
// Check capabilities
if (fieldSupportsMany(field) && field.hasMany) {
console.log('Multiple values supported')
}
}
```
## Plugins
### Using Plugins
```typescript
import { seoPlugin } from '@payloadcms/plugin-seo'
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
export default buildConfig({
plugins: [
seoPlugin({
collections: ['posts', 'pages'],
}),
redirectsPlugin({
collections: ['pages'],
}),
],
})
```
### Creating Plugins
```typescript
import type { Config, Plugin } from 'payload'
interface MyPluginConfig {
collections?: string[]
enabled?: boolean
}
export const myPlugin =
(options: MyPluginConfig): Plugin =>
(config: Config): Config => ({
...config,
collections: config.collections?.map((collection) => {
if (options.collections?.includes(collection.slug)) {
return {
...collection,
fields: [...collection.fields, { name: 'pluginField', type: 'text' }],
}
}
return collection
}),
})
```
## Best Practices
### Security
1. Always set `overrideAccess: false` when passing `user` to Local API
2. Field-level access only returns boolean (no query constraints)
3. Default to restrictive access, gradually add permissions
4. Never trust client-provided data
5. Use `saveToJWT: true` for roles to avoid database lookups
### Performance
1. Index frequently queried fields
2. Use `select` to limit returned fields
3. Set `maxDepth` on relationships to prevent over-fetching
4. Use query constraints over async operations in access control
5. Cache expensive operations in `req.context`
### Data Integrity
1. Always pass `req` to nested operations in hooks
2. Use context flags to prevent infinite hook loops
3. Enable transactions for MongoDB (requires replica set) and Postgres
4. Use `beforeValidate` for data formatting
5. Use `beforeChange` for business logic
### Type Safety
1. Run `generate:types` after schema changes
2. Import types from generated `payload-types.ts`
3. Type your user object: `import type { User } from '@/payload-types'`
4. Use `as const` for field options
5. Use field type guards for runtime type checking
### Organization
1. Keep collections in separate files
2. Extract access control to `access/` directory
3. Extract hooks to `hooks/` directory
4. Use reusable field factories for common patterns
5. Document complex access control with comments
## Common Gotchas
1. **Local API Default**: Access control bypassed unless `overrideAccess: false`
2. **Transaction Safety**: Missing `req` in nested operations breaks atomicity
3. **Hook Loops**: Operations in hooks can trigger the same hooks
4. **Field Access**: Cannot use query constraints, only boolean
5. **Relationship Depth**: Default depth is 2, set to 0 for IDs only
6. **Draft Status**: `_status` field auto-injected when drafts enabled
7. **Type Generation**: Types not updated until `generate:types` runs
8. **MongoDB Transactions**: Require replica set configuration
9. **SQLite Transactions**: Disabled by default, enable with `transactionOptions: {}`
10. **Point Fields**: Not supported in SQLite
## Additional Context Files
For deeper exploration of specific topics, refer to the context files located in `.cursor/rules/`:
### Available Context Files
1. **`payload-overview.md`** - High-level architecture and core concepts
- Payload structure and initialization
- Configuration fundamentals
- Database adapters overview
2. **`security-critical.md`** - Critical security patterns (⚠️ IMPORTANT)
- Local API access control
- Transaction safety in hooks
- Preventing infinite hook loops
3. **`collections.md`** - Collection configurations
- Basic collection patterns
- Auth collections with RBAC
- Upload collections
- Drafts and versioning
- Globals
4. **`fields.md`** - Field types and patterns
- All field types with examples
- Conditional fields
- Virtual fields
- Field validation
- Common field patterns
5. **`field-type-guards.md`** - TypeScript field type utilities
- Field type checking utilities
- Safe type narrowing
- Runtime field validation
6. **`access-control.md`** - Permission patterns
- Collection-level access
- Field-level access
- Row-level security
- RBAC patterns
- Multi-tenant access control
7. **`access-control-advanced.md`** - Complex access patterns
- Nested document access
- Cross-collection permissions
- Dynamic role hierarchies
- Performance optimization
8. **`hooks.md`** - Lifecycle hooks
- Collection hooks
- Field hooks
- Hook context patterns
- Common hook recipes
9. **`queries.md`** - Database operations
- Local API usage
- Query operators
- Complex queries with AND/OR
- Performance optimization
10. **`endpoints.md`** - Custom API endpoints
- REST endpoint patterns
- Authentication in endpoints
- Error handling
- Route parameters
11. **`adapters.md`** - Database and storage adapters
- MongoDB, PostgreSQL, SQLite patterns
- Storage adapter usage (S3, Azure, GCS, etc.)
- Custom adapter development
12. **`plugin-development.md`** - Creating plugins
- Plugin architecture
- Modifying configuration
- Plugin hooks
- Best practices
13. **`components.md`** - Custom Components
- Component types (Root, Collection, Global, Field)
- Server vs Client Components
- Component paths and definition
- Default and custom props
- Using hooks
- Performance best practices
- Styling components
## Resources
- Docs: https://payloadcms.com/docs
- LLM Context: https://payloadcms.com/llms-full.txt
- GitHub: https://github.com/payloadcms/payload
- Examples: https://github.com/payloadcms/payload/tree/main/examples
- Templates: https://github.com/payloadcms/payload/tree/main/templates