You are an expert Payload CMS developer. When working with Payload projects, follow these rules:
- TypeScript-First: Always use TypeScript with proper types from Payload
- Security-Critical: Follow all security patterns, especially access control
- Type Generation: Run
generate:typesscript after schema changes - Transaction Safety: Always pass
reqto nested operations in hooks - Access Control: Understand Local API bypasses access control by default
- Access Control: Ensure roles exist when modifiyng collection or globals with access controls
- To validate typescript correctness after modifying code run
tsc --noEmit - Generate import maps after creating or modifying components.
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
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,
}),
})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,
}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'),
},
},
],
}// 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}`],
},
}// ❌ 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
// ❌ 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
// ❌ 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,
})
},
],
}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 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')
},
},
}// 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' } }
}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
})
},
],
},
}// 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',
})// 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] } }{
or: [
{ status: { equals: 'published' } },
{ author: { equals: user.id } },
],
}
{
and: [
{ status: { equals: 'published' } },
{ featured: { equals: true } },
],
}// 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>
}The Admin Panel can be extensively customized using React Components. Custom Components can be Server Components (default) or Client 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
#ExportNamesuffix orexportNameproperty - Default exports: no suffix needed
- File extensions can be omitted
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
#ExportNamesuffix orexportNameproperty - Default exports: no suffix needed
- File extensions can be omitted
- Root Components - Global Admin Panel (logo, nav, header)
- Collection Components - Collection-specific (edit view, list view)
- Global Components - Global document views
- Field Components - Custom field UI and cells
- Root Components - Global Admin Panel (logo, nav, header)
- Collection Components - Collection-specific (edit view, list view)
- Global Components - Global document views
- Field Components - Custom field UI and cells
All components are Server Components by default (can use Local API directly):
// 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 MyServerComponentClient Components need the 'use client' directive:
'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>
)
}'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>
}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'],
},
},
},
}{
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):
{
name: 'refundButton',
type: 'ui',
admin: {
components: {
Field: '/components/RefundButton',
},
},
}-
Import correctly:
- Admin Panel:
import { Button } from '@payloadcms/ui' - Frontend:
import { Button } from '@payloadcms/ui/elements/Button'
- Admin Panel:
-
Optimize re-renders:
// ❌ BAD: Re-renders on every form change const { fields } = useForm() // ✅ GOOD: Only re-renders when specific field changes const value = useFormFields(([fields]) => fields[path])
-
Prefer Server Components - Only use Client Components when you need:
- State (useState, useReducer)
- Effects (useEffect)
- Event handlers (onClick, onChange)
- Browser APIs (localStorage, window)
-
Minimize serialized props - Server Components serialize props sent to client
import './styles.scss'
export function MyComponent() {
return <div className="my-component">Content</div>
}// 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);
}
}import type {
TextFieldServerComponent,
TextFieldClientComponent,
TextFieldCellComponent,
SelectFieldServerComponent,
// ... etc
} from 'payload'
export const MyField: TextFieldClientComponent = (props) => {
// Fully typed props
}Payload auto-generates app/(payload)/admin/importMap.js to resolve component paths.
Regenerate manually:
payload generate:importmapSet custom location:
export default buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname, 'src'),
importMapFile: path.resolve(dirname, 'app', 'custom-import-map.js'),
},
},
})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)
},
}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
})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')
}
}import { seoPlugin } from '@payloadcms/plugin-seo'
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
export default buildConfig({
plugins: [
seoPlugin({
collections: ['posts', 'pages'],
}),
redirectsPlugin({
collections: ['pages'],
}),
],
})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
}),
})- Always set
overrideAccess: falsewhen passinguserto Local API - Field-level access only returns boolean (no query constraints)
- Default to restrictive access, gradually add permissions
- Never trust client-provided data
- Use
saveToJWT: truefor roles to avoid database lookups
- Index frequently queried fields
- Use
selectto limit returned fields - Set
maxDepthon relationships to prevent over-fetching - Use query constraints over async operations in access control
- Cache expensive operations in
req.context
- Always pass
reqto nested operations in hooks - Use context flags to prevent infinite hook loops
- Enable transactions for MongoDB (requires replica set) and Postgres
- Use
beforeValidatefor data formatting - Use
beforeChangefor business logic
- Run
generate:typesafter schema changes - Import types from generated
payload-types.ts - Type your user object:
import type { User } from '@/payload-types' - Use
as constfor field options - Use field type guards for runtime type checking
- 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
- Local API Default: Access control bypassed unless
overrideAccess: false - Transaction Safety: Missing
reqin nested operations breaks atomicity - Hook Loops: Operations in hooks can trigger the same hooks
- Field Access: Cannot use query constraints, only boolean
- Relationship Depth: Default depth is 2, set to 0 for IDs only
- Draft Status:
_statusfield auto-injected when drafts enabled - Type Generation: Types not updated until
generate:typesruns - MongoDB Transactions: Require replica set configuration
- SQLite Transactions: Disabled by default, enable with
transactionOptions: {} - Point Fields: Not supported in SQLite
For deeper exploration of specific topics, refer to the context files located in .cursor/rules/:
-
payload-overview.md- High-level architecture and core concepts- Payload structure and initialization
- Configuration fundamentals
- Database adapters overview
-
security-critical.md- Critical security patterns (⚠️ IMPORTANT)- Local API access control
- Transaction safety in hooks
- Preventing infinite hook loops
-
collections.md- Collection configurations- Basic collection patterns
- Auth collections with RBAC
- Upload collections
- Drafts and versioning
- Globals
-
fields.md- Field types and patterns- All field types with examples
- Conditional fields
- Virtual fields
- Field validation
- Common field patterns
-
field-type-guards.md- TypeScript field type utilities- Field type checking utilities
- Safe type narrowing
- Runtime field validation
-
access-control.md- Permission patterns- Collection-level access
- Field-level access
- Row-level security
- RBAC patterns
- Multi-tenant access control
-
access-control-advanced.md- Complex access patterns- Nested document access
- Cross-collection permissions
- Dynamic role hierarchies
- Performance optimization
-
hooks.md- Lifecycle hooks- Collection hooks
- Field hooks
- Hook context patterns
- Common hook recipes
-
queries.md- Database operations- Local API usage
- Query operators
- Complex queries with AND/OR
- Performance optimization
-
endpoints.md- Custom API endpoints- REST endpoint patterns
- Authentication in endpoints
- Error handling
- Route parameters
-
adapters.md- Database and storage adapters- MongoDB, PostgreSQL, SQLite patterns
- Storage adapter usage (S3, Azure, GCS, etc.)
- Custom adapter development
-
plugin-development.md- Creating plugins- Plugin architecture
- Modifying configuration
- Plugin hooks
- Best practices
-
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