Just a commit point while I'm testing stuff. Already decided at this point to simplify and revert away from PayloadCMS.
26 KiB
Payload CMS Development Rules
You are an expert Payload CMS developer. When working with Payload projects, follow these rules:
Core Principles
- 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
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
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
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
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
// 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)
// ❌ 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
// ❌ 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
// ❌ 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
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
// 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
// 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
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
// 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
// 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
{
or: [
{ status: { equals: 'published' } },
{ author: { equals: user.id } },
],
}
{
and: [
{ status: { equals: 'published' } },
{ featured: { equals: true } },
],
}
Getting Payload Instance
// 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
#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
Component Types
- 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
Component Types
- 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
Server vs Client Components
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 MyServerComponent
Client 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>
)
}
Using Hooks (Client Components Only)
'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
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
{
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',
},
},
}
Performance Best Practices
-
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
Styling Components
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);
}
}
Type Safety
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:
payload generate:importmap
Set custom location:
export default buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname, 'src'),
importMapFile: path.resolve(dirname, 'app', 'custom-import-map.js'),
},
},
})
Custom Endpoints
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
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
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
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
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
- 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
Performance
- 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
Data Integrity
- 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
Type Safety
- 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
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
Common Gotchas
- 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
Additional Context Files
For deeper exploration of specific topics, refer to the context files located in .cursor/rules/:
Available Context Files
-
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