# 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
{docs.map(post =>

{post.title}

)}
} ``` ## 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
{posts.totalDocs} posts
} 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 ( ) } ``` ### 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
Hello {user?.email}
} ``` ### 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
Content
} ``` ```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