mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -06:00
Just a commit point while I'm testing stuff. Already decided at this point to simplify and revert away from PayloadCMS.
1142 lines
26 KiB
Markdown
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
|