feat: initial commit

This commit is contained in:
龟男日记\www 2026-02-08 03:07:00 +08:00
commit 1692cee9aa
55 changed files with 14880 additions and 0 deletions

View File

@ -0,0 +1,519 @@
---
title: Access Control - Advanced Patterns
description: Context-aware, time-based, subscription-based access, factory functions, templates
tags: [payload, access-control, security, advanced, performance]
priority: high
---
# Advanced Access Control Patterns
Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates.
## Context-Aware Access Patterns
### Locale-Specific Access
```typescript
import type { Access } from 'payload'
export const localeSpecificAccess: Access = ({ req: { user, locale } }) => {
// Authenticated users can access all locales
if (user) return true
// Public users can only access English content
if (locale === 'en') return true
return false
}
```
### Device-Specific Access
```typescript
export const mobileOnlyAccess: Access = ({ req: { headers } }) => {
const userAgent = headers?.get('user-agent') || ''
return /mobile|android|iphone/i.test(userAgent)
}
export const desktopOnlyAccess: Access = ({ req: { headers } }) => {
const userAgent = headers?.get('user-agent') || ''
return !/mobile|android|iphone/i.test(userAgent)
}
```
### IP-Based Access
```typescript
export const restrictedIpAccess = (allowedIps: string[]): Access => {
return ({ req: { headers } }) => {
const ip = headers?.get('x-forwarded-for') || headers?.get('x-real-ip')
return allowedIps.includes(ip || '')
}
}
// Usage
const internalIps = ['192.168.1.0/24', '10.0.0.5']
export const InternalDocs: CollectionConfig = {
slug: 'internal-docs',
access: {
read: restrictedIpAccess(internalIps),
},
}
```
## Time-Based Access Patterns
### Today's Records Only
```typescript
export const todayOnlyAccess: Access = ({ req: { user } }) => {
if (!user) return false
const now = new Date()
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
return {
createdAt: {
greater_than_equal: startOfDay.toISOString(),
less_than: endOfDay.toISOString(),
},
}
}
```
### Recent Records (Last N Days)
```typescript
export const recentRecordsAccess = (days: number): Access => {
return ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - days)
return {
createdAt: {
greater_than_equal: cutoff.toISOString(),
},
}
}
}
// Usage: Users see only last 30 days, admins see all
export const Logs: CollectionConfig = {
slug: 'logs',
access: {
read: recentRecordsAccess(30),
},
}
```
### Scheduled Content (Publish Date Range)
```typescript
export const scheduledContentAccess: Access = ({ req: { user } }) => {
// Editors see all content
if (user?.roles?.includes('admin') || user?.roles?.includes('editor')) {
return true
}
const now = new Date().toISOString()
// Public sees only content within publish window
return {
and: [
{ publishDate: { less_than_equal: now } },
{
or: [{ unpublishDate: { exists: false } }, { unpublishDate: { greater_than: now } }],
},
],
}
}
```
## Subscription-Based Access
### Active Subscription Required
```typescript
export const activeSubscriptionAccess: Access = async ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
try {
const subscription = await req.payload.findByID({
collection: 'subscriptions',
id: user.subscriptionId,
})
return subscription?.status === 'active'
} catch {
return false
}
}
```
### Subscription Tier-Based Access
```typescript
export const tierBasedAccess = (requiredTier: string): Access => {
const tierHierarchy = ['free', 'basic', 'pro', 'enterprise']
return async ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
try {
const subscription = await req.payload.findByID({
collection: 'subscriptions',
id: user.subscriptionId,
})
if (subscription?.status !== 'active') return false
const userTierIndex = tierHierarchy.indexOf(subscription.tier)
const requiredTierIndex = tierHierarchy.indexOf(requiredTier)
return userTierIndex >= requiredTierIndex
} catch {
return false
}
}
}
// Usage
export const EnterpriseFeatures: CollectionConfig = {
slug: 'enterprise-features',
access: {
read: tierBasedAccess('enterprise'),
},
}
```
## Factory Functions
### createRoleBasedAccess
```typescript
export function createRoleBasedAccess(roles: string[]): Access {
return ({ req: { user } }) => {
if (!user) return false
return roles.some((role) => user.roles?.includes(role))
}
}
// Usage
const adminOrEditor = createRoleBasedAccess(['admin', 'editor'])
const moderatorAccess = createRoleBasedAccess(['admin', 'moderator'])
```
### createOrgScopedAccess
```typescript
export function createOrgScopedAccess(allowAdmin = true): Access {
return ({ req: { user } }) => {
if (!user) return false
if (allowAdmin && user.roles?.includes('admin')) return true
return {
organizationId: { in: user.organizationIds || [] },
}
}
}
// Usage
const orgScoped = createOrgScopedAccess() // Admins bypass
const strictOrgScoped = createOrgScopedAccess(false) // Admins also scoped
```
### createTeamBasedAccess
```typescript
export function createTeamBasedAccess(teamField = 'teamId'): Access {
return ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
return {
[teamField]: { in: user.teamIds || [] },
}
}
}
```
### createTimeLimitedAccess
```typescript
export function createTimeLimitedAccess(daysAccess: number): Access {
return ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - daysAccess)
return {
createdAt: {
greater_than_equal: cutoff.toISOString(),
},
}
}
}
```
## Configuration Templates
### Public + Authenticated Collection
```typescript
export const PublicAuthCollection: CollectionConfig = {
slug: 'posts',
access: {
// Only admins/editors can create
create: ({ req: { user } }) => {
return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
},
// Authenticated users see all, public sees only published
read: ({ req: { user } }) => {
if (user) return true
return { _status: { equals: 'published' } }
},
// Only admins/editors can update
update: ({ req: { user } }) => {
return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
},
// Only admins can delete
delete: ({ req: { user } }) => {
return user?.roles?.includes('admin') || false
},
},
versions: {
drafts: true,
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'content', type: 'richText', required: true },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
}
```
### Self-Service Collection
```typescript
export const SelfServiceCollection: CollectionConfig = {
slug: 'users',
auth: true,
access: {
// Admins can create users
create: ({ req: { user } }) => user?.roles?.includes('admin') || false,
// Anyone can read user profiles
read: () => true,
// Users can update self, admins can update anyone
update: ({ req: { user }, id }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
return user.id === id
},
// Only admins can delete
delete: ({ req: { user } }) => user?.roles?.includes('admin') || false,
},
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
access: {
// Only admins can read/update roles
read: ({ req: { user } }) => user?.roles?.includes('admin') || false,
update: ({ req: { user } }) => user?.roles?.includes('admin') || false,
},
},
],
}
```
## Performance Considerations
### Avoid Async Operations in Hot Paths
```typescript
// ❌ Slow: Multiple sequential async calls
export const slowAccess: Access = async ({ req: { user } }) => {
const org = await req.payload.findByID({ collection: 'orgs', id: user.orgId })
const team = await req.payload.findByID({ collection: 'teams', id: user.teamId })
const subscription = await req.payload.findByID({ collection: 'subs', id: user.subId })
return org.active && team.active && subscription.active
}
// ✅ Fast: Use query constraints or cache in context
export const fastAccess: Access = ({ req: { user, context } }) => {
// Cache expensive lookups
if (!context.orgStatus) {
context.orgStatus = checkOrgStatus(user.orgId)
}
return context.orgStatus
}
```
### Query Constraint Optimization
```typescript
// ❌ Avoid: Non-indexed fields in constraints
export const slowQuery: Access = () => ({
'metadata.internalCode': { equals: 'ABC123' }, // Slow if not indexed
})
// ✅ Better: Use indexed fields
export const fastQuery: Access = () => ({
status: { equals: 'active' }, // Indexed field
organizationId: { in: ['org1', 'org2'] }, // Indexed field
})
```
### Field Access on Large Arrays
```typescript
// ❌ Slow: Complex access on array fields
{
name: 'items',
type: 'array',
fields: [
{
name: 'secretData',
type: 'text',
access: {
read: async ({ req }) => {
// Async call runs for EVERY array item
const result = await expensiveCheck()
return result
},
},
},
],
}
// ✅ Fast: Simple checks or cache result
{
name: 'items',
type: 'array',
fields: [
{
name: 'secretData',
type: 'text',
access: {
read: ({ req: { user }, context }) => {
// Cache once, reuse for all items
if (context.canReadSecret === undefined) {
context.canReadSecret = user?.roles?.includes('admin')
}
return context.canReadSecret
},
},
},
],
}
```
### Avoid N+1 Queries
```typescript
// ❌ N+1 Problem: Query per access check
export const n1Access: Access = async ({ req, id }) => {
// Runs for EACH document in list
const doc = await req.payload.findByID({ collection: 'docs', id })
return doc.isPublic
}
// ✅ Better: Use query constraint to filter at DB level
export const efficientAccess: Access = () => {
return { isPublic: { equals: true } }
}
```
## Debugging Tips
### Log Access Check Execution
```typescript
export const debugAccess: Access = ({ req: { user }, id }) => {
console.log('Access check:', {
userId: user?.id,
userRoles: user?.roles,
docId: id,
timestamp: new Date().toISOString(),
})
return true
}
```
### Verify Arguments Availability
```typescript
export const checkArgsAccess: Access = (args) => {
console.log('Available arguments:', {
hasReq: 'req' in args,
hasUser: args.req?.user ? 'yes' : 'no',
hasId: args.id ? 'provided' : 'undefined',
hasData: args.data ? 'provided' : 'undefined',
})
return true
}
```
### Test Access Without User
```typescript
// In test/development
const testAccess = await payload.find({
collection: 'posts',
overrideAccess: false, // Enforce access control
user: undefined, // Simulate no user
})
console.log('Public access result:', testAccess.docs.length)
```
## Best Practices
1. **Default Deny**: Start with restrictive access, gradually add permissions
2. **Type Guards**: Use TypeScript for user type safety
3. **Validate Data**: Never trust frontend-provided IDs or data
4. **Async for Critical Checks**: Use async operations for important security decisions
5. **Consistent Logic**: Apply same rules at field and collection levels
6. **Test Edge Cases**: Test with no user, wrong user, admin user scenarios
7. **Monitor Access**: Log failed access attempts for security review
8. **Regular Audit**: Review access rules quarterly or after major changes
9. **Cache Wisely**: Use `req.context` for expensive operations
10. **Document Intent**: Add comments explaining complex access rules
11. **Avoid Secrets in Client**: Never expose sensitive logic to client-side
12. **Handle Errors Gracefully**: Access functions should return `false` on error, not throw
13. **Test Local API**: Remember to set `overrideAccess: false` when testing
14. **Consider Performance**: Measure impact of async operations
15. **Principle of Least Privilege**: Grant minimum access required
## Performance Summary
**Minimize Async Operations**: Use query constraints over async lookups when possible
**Cache Expensive Checks**: Store results in `req.context` for reuse
**Index Query Fields**: Ensure fields in query constraints are indexed
**Avoid Complex Logic in Array Fields**: Simple boolean checks preferred
**Use Query Constraints**: Let database filter rather than loading all records

View File

@ -0,0 +1,225 @@
---
title: Access Control
description: Collection, field, and global access control patterns
tags: [payload, access-control, security, permissions, rbac]
---
# Payload CMS Access Control
## Access Control Layers
1. **Collection-Level**: Controls operations on entire documents (create, read, update, delete, admin)
2. **Field-Level**: Controls access to individual fields (create, read, update)
3. **Global-Level**: Controls access to global documents (read, update)
## Collection Access Control
```typescript
import type { Access } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
// Boolean: Only authenticated users can create
create: ({ req: { user } }) => Boolean(user),
// Query constraint: Public sees published, users see all
read: ({ req: { user } }) => {
if (user) return true
return { status: { equals: 'published' } }
},
// User-specific: Admins or document owner
update: ({ req: { user }, id }) => {
if (user?.roles?.includes('admin')) return true
return { author: { equals: user?.id } }
},
// Async: Check related data
delete: async ({ req, id }) => {
const hasComments = await req.payload.count({
collection: 'comments',
where: { post: { equals: id } },
})
return hasComments === 0
},
},
}
```
## 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' } }
}
```
## Row-Level Security
```typescript
// Organization-scoped access
export const organizationScoped: Access = ({ req: { user } }) => {
if (user?.roles?.includes('admin')) return true
// Users see only their organization's data
return {
organization: {
equals: user?.organization,
},
}
}
// Team-based access
export const teamMemberAccess: Access = ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
return {
'team.members': {
contains: user.id,
},
}
}
```
## Field Access Control
**Field access ONLY returns boolean** (no query constraints).
```typescript
{
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')
},
},
}
```
## RBAC Pattern
Payload does NOT provide a roles system by default. Add a `roles` field to your auth collection:
```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'),
},
},
],
}
```
## Multi-Tenant Access Control
```typescript
interface User {
id: string
tenantId: string
roles?: string[]
}
const tenantAccess: Access = ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('super-admin')) return true
return {
tenant: {
equals: (user as User).tenantId,
},
}
}
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: tenantAccess,
read: tenantAccess,
update: tenantAccess,
delete: tenantAccess,
},
fields: [
{
name: 'tenant',
type: 'text',
required: true,
access: {
update: ({ req: { user } }) => user?.roles?.includes('super-admin'),
},
hooks: {
beforeChange: [
({ req, operation, value }) => {
if (operation === 'create' && !value) {
return (req.user as User)?.tenantId
}
return value
},
],
},
},
],
}
```
## Important Notes
1. **Local API Default**: Access control is **skipped by default** in Local API (`overrideAccess: true`). When passing a `user` parameter, you must set `overrideAccess: false`:
```typescript
// ❌ WRONG: Passes user but bypasses access control
await payload.find({
collection: 'posts',
user: someUser,
})
// ✅ CORRECT: Respects the user's permissions
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // Required to enforce access control
})
```
2. **Field Access Limitations**: Field-level access does NOT support query constraints - only boolean returns.
3. **Admin Panel Visibility**: The `admin` access control determines if a collection appears in the admin panel for a user.

209
.cursor/rules/adapters.md Normal file
View File

@ -0,0 +1,209 @@
---
title: Database Adapters & Transactions
description: Database adapters, storage, email, and transaction patterns
tags: [payload, database, mongodb, postgres, sqlite, transactions]
---
# Payload CMS Adapters
## Database Adapters
### MongoDB
```typescript
import { mongooseAdapter } from '@payloadcms/db-mongodb'
export default buildConfig({
db: mongooseAdapter({
url: process.env.DATABASE_URL,
}),
})
```
### Postgres
```typescript
import { postgresAdapter } from '@payloadcms/db-postgres'
export default buildConfig({
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL,
},
push: false, // Don't auto-push schema changes
migrationDir: './migrations',
}),
})
```
### SQLite
```typescript
import { sqliteAdapter } from '@payloadcms/db-sqlite'
export default buildConfig({
db: sqliteAdapter({
client: {
url: 'file:./payload.db',
},
transactionOptions: {}, // Enable transactions (disabled by default)
}),
})
```
## Transactions
Payload automatically uses transactions for all-or-nothing database operations.
### Threading req Through Operations
**CRITICAL**: When performing nested operations in hooks, always pass `req` to maintain transaction context.
```typescript
// ✅ CORRECT: Thread req through nested operations
const resaveChildren: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
// Find children - pass req
const children = await req.payload.find({
collection: 'children',
where: { parent: { equals: doc.id } },
req, // Maintains transaction context
})
// Update each child - pass req
for (const child of children.docs) {
await req.payload.update({
id: child.id,
collection: 'children',
data: { updatedField: 'value' },
req, // Same transaction as parent operation
})
}
}
// ❌ WRONG: Missing req breaks transaction
const brokenHook: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
const children = await req.payload.find({
collection: 'children',
where: { parent: { equals: doc.id } },
// Missing req - separate transaction or no transaction
})
for (const child of children.docs) {
await req.payload.update({
id: child.id,
collection: 'children',
data: { updatedField: 'value' },
// Missing req - if parent operation fails, these updates persist
})
}
}
```
**Why This Matters:**
- **MongoDB (with replica sets)**: Creates atomic session across operations
- **PostgreSQL**: All operations use same Drizzle transaction
- **SQLite (with transactions enabled)**: Ensures rollback on errors
- **Without req**: Each operation runs independently, breaking atomicity
### Manual Transaction Control
```typescript
const transactionID = await payload.db.beginTransaction()
try {
await payload.create({
collection: 'orders',
data: orderData,
req: { transactionID },
})
await payload.update({
collection: 'inventory',
id: itemId,
data: { stock: newStock },
req: { transactionID },
})
await payload.db.commitTransaction(transactionID)
} catch (error) {
await payload.db.rollbackTransaction(transactionID)
throw error
}
```
## Storage Adapters
Available storage adapters:
- **@payloadcms/storage-s3** - AWS S3
- **@payloadcms/storage-azure** - Azure Blob Storage
- **@payloadcms/storage-gcs** - Google Cloud Storage
- **@payloadcms/storage-r2** - Cloudflare R2
- **@payloadcms/storage-vercel-blob** - Vercel Blob
- **@payloadcms/storage-uploadthing** - Uploadthing
### AWS S3
```typescript
import { s3Storage } from '@payloadcms/storage-s3'
export default buildConfig({
plugins: [
s3Storage({
collections: {
media: true,
},
bucket: process.env.S3_BUCKET,
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
region: process.env.S3_REGION,
},
}),
],
})
```
## Email Adapters
### Nodemailer (SMTP)
```typescript
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
export default buildConfig({
email: nodemailerAdapter({
defaultFromAddress: 'noreply@example.com',
defaultFromName: 'My App',
transportOptions: {
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
}),
})
```
### Resend
```typescript
import { resendAdapter } from '@payloadcms/email-resend'
export default buildConfig({
email: resendAdapter({
defaultFromAddress: 'noreply@example.com',
defaultFromName: 'My App',
apiKey: process.env.RESEND_API_KEY,
}),
})
```
## Important Notes
1. **MongoDB Transactions**: Require replica set configuration
2. **SQLite Transactions**: Disabled by default, enable with `transactionOptions: {}`
3. **Pass req**: Always pass `req` to nested operations in hooks for transaction safety
4. **Point Fields**: Not supported in SQLite

View File

@ -0,0 +1,171 @@
---
title: Collections
description: Collection configurations and patterns
tags: [payload, collections, auth, upload, drafts]
---
# Payload CMS 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'),
},
},
],
}
```
## Upload Collection
```typescript
export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: 'media',
mimeTypes: ['image/*'],
imageSizes: [
{
name: 'thumbnail',
width: 400,
height: 300,
position: 'centre',
},
{
name: 'card',
width: 768,
height: 1024,
},
],
adminThumbnail: 'thumbnail',
focalPoint: true,
crop: true,
},
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
],
}
```
## Versioning & Drafts
```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
},
},
}
```
### Draft API Usage
```typescript
// Create draft
await payload.create({
collection: 'posts',
data: { title: 'Draft Post' },
draft: true, // Skips required field validation
})
// Read with drafts
const page = await payload.findByID({
collection: 'pages',
id: '123',
draft: true, // Returns draft version if exists
})
```
## Globals
Globals are single-instance documents (not collections).
```typescript
import type { GlobalConfig } from 'payload'
export const Header: GlobalConfig = {
slug: 'header',
label: 'Header',
admin: {
group: 'Settings',
},
fields: [
{
name: 'logo',
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'nav',
type: 'array',
maxRows: 8,
fields: [
{
name: 'link',
type: 'relationship',
relationTo: 'pages',
},
{
name: 'label',
type: 'text',
},
],
},
],
}
```

794
.cursor/rules/components.md Normal file
View File

@ -0,0 +1,794 @@
# Custom Components in Payload CMS
Custom Components allow you to fully customize the Admin Panel by swapping in your own React components. You can replace nearly every part of the interface or add entirely new functionality.
## Component Types
There are four main types of Custom Components:
1. **Root Components** - Affect the Admin Panel globally (logo, nav, header)
2. **Collection Components** - Specific to collection views
3. **Global Components** - Specific to global document views
4. **Field Components** - Custom field UI and cells
## Defining Custom Components
### Component Paths
Components are defined using file paths (not direct imports) to keep the config lightweight and Node.js compatible.
```typescript
import { buildConfig } from 'payload'
export default buildConfig({
admin: {
components: {
logout: {
Button: '/src/components/Logout#MyComponent', // Named export
},
Nav: '/src/components/Nav', // Default export
},
},
})
```
**Component Path Rules:**
1. Paths are relative to project root (or `config.admin.importMap.baseDir`)
2. For **named exports**: append `#ExportName` or use `exportName` property
3. For **default exports**: no suffix needed
4. File extensions can be omitted
### Component Config Object
Instead of a string path, you can pass a config object:
```typescript
{
logout: {
Button: {
path: '/src/components/Logout',
exportName: 'MyComponent',
clientProps: { customProp: 'value' },
serverProps: { asyncData: someData },
},
},
}
```
**Config Properties:**
| Property | Description |
| ------------- | ----------------------------------------------------- |
| `path` | File path to component (named exports via `#`) |
| `exportName` | Named export (alternative to `#` in path) |
| `clientProps` | Props for Client Components (must be serializable) |
| `serverProps` | Props for Server Components (can be non-serializable) |
### Setting Base Directory
```typescript
import path from 'path'
import { fileURLToPath } from 'node:url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname, 'src'), // Set base directory
},
components: {
Nav: '/components/Nav', // Now relative to src/
},
},
})
```
## Server vs Client Components
**All components are React Server Components by default.**
### Server Components (Default)
Can use Local API directly, perform async operations, and access full Payload instance.
```tsx
import React from 'react'
import type { Payload } from 'payload'
async function MyServerComponent({ payload }: { payload: Payload }) {
const page = await payload.findByID({
collection: 'pages',
id: '123',
})
return <p>{page.title}</p>
}
export default MyServerComponent
```
### Client Components
Use the `'use client'` directive for interactivity, hooks, state, etc.
```tsx
'use client'
import React, { useState } from 'react'
export function MyClientComponent() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
}
```
**Important:** Client Components cannot receive non-serializable props (functions, class instances, etc.). Payload automatically strips these when passing to client components.
## Default Props
All Custom Components receive these props by default:
| Prop | Description | Type |
| --------- | ---------------------------------------- | --------- |
| `payload` | Payload instance (Local API access) | `Payload` |
| `i18n` | Internationalization object | `I18n` |
| `locale` | Current locale (if localization enabled) | `string` |
**Server Component Example:**
```tsx
async function MyComponent({ payload, i18n, locale }) {
const data = await payload.find({
collection: 'posts',
locale,
})
return <div>{data.docs.length} posts</div>
}
```
**Client Component Example:**
```tsx
'use client'
import { usePayload, useLocale, useTranslation } from '@payloadcms/ui'
export function MyComponent() {
// Access via hooks in client components
const { getLocal, getByID } = usePayload()
const locale = useLocale()
const { t, i18n } = useTranslation()
return <div>{t('myKey')}</div>
}
```
## Custom Props
Pass additional props using `clientProps` or `serverProps`:
```typescript
{
logout: {
Button: {
path: '/components/Logout',
clientProps: {
buttonText: 'Sign Out',
onLogout: () => console.log('Logged out'),
},
},
},
}
```
Receive in component:
```tsx
'use client'
export function Logout({ buttonText, onLogout }) {
return <button onClick={onLogout}>{buttonText}</button>
}
```
## Root Components
Root Components affect the entire Admin Panel.
### Available Root Components
| Component | Description | Config Path |
| ----------------- | -------------------------------- | ---------------------------------- |
| `Nav` | Entire navigation sidebar | `admin.components.Nav` |
| `graphics.Icon` | Small icon (used in nav) | `admin.components.graphics.Icon` |
| `graphics.Logo` | Full logo (used on login) | `admin.components.graphics.Logo` |
| `logout.Button` | Logout button | `admin.components.logout.Button` |
| `actions` | Header actions (array) | `admin.components.actions` |
| `header` | Above header (array) | `admin.components.header` |
| `beforeDashboard` | Before dashboard content (array) | `admin.components.beforeDashboard` |
| `afterDashboard` | After dashboard content (array) | `admin.components.afterDashboard` |
| `beforeLogin` | Before login form (array) | `admin.components.beforeLogin` |
| `afterLogin` | After login form (array) | `admin.components.afterLogin` |
| `beforeNavLinks` | Before nav links (array) | `admin.components.beforeNavLinks` |
| `afterNavLinks` | After nav links (array) | `admin.components.afterNavLinks` |
| `settingsMenu` | Settings menu items (array) | `admin.components.settingsMenu` |
| `providers` | Custom React Context providers | `admin.components.providers` |
| `views` | Custom views (dashboard, etc.) | `admin.components.views` |
### Example: Custom Logo
```typescript
export default buildConfig({
admin: {
components: {
graphics: {
Logo: '/components/Logo',
Icon: '/components/Icon',
},
},
},
})
```
```tsx
// components/Logo.tsx
export default function Logo() {
return <img src="/logo.png" alt="My Brand" width={200} />
}
```
### Example: Header Actions
```typescript
export default buildConfig({
admin: {
components: {
actions: ['/components/ClearCacheButton', '/components/PreviewButton'],
},
},
})
```
```tsx
// components/ClearCacheButton.tsx
'use client'
export default function ClearCacheButton() {
return (
<button
onClick={async () => {
await fetch('/api/clear-cache', { method: 'POST' })
alert('Cache cleared!')
}}
>
Clear Cache
</button>
)
}
```
## Collection Components
Collection Components are specific to a collection's views.
```typescript
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
components: {
// Edit view components
edit: {
PreviewButton: '/components/PostPreview',
SaveButton: '/components/CustomSave',
SaveDraftButton: '/components/CustomSaveDraft',
PublishButton: '/components/CustomPublish',
},
// List view components
list: {
Header: '/components/PostsListHeader',
beforeList: ['/components/ListFilters'],
afterList: ['/components/ListFooter'],
},
},
},
fields: [
// ...
],
}
```
## Global Components
Similar to Collection Components but for Global documents.
```typescript
import type { GlobalConfig } from 'payload'
export const Settings: GlobalConfig = {
slug: 'settings',
admin: {
components: {
edit: {
PreviewButton: '/components/SettingsPreview',
SaveButton: '/components/SettingsSave',
},
},
},
fields: [
// ...
],
}
```
## Field Components
Customize how fields render in Edit and List views.
### Field Component (Edit View)
```typescript
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
admin: {
components: {
Field: '/components/StatusField',
},
},
}
```
```tsx
// components/StatusField.tsx
'use client'
import { useField } from '@payloadcms/ui'
import type { SelectFieldClientComponent } from 'payload'
export const StatusField: SelectFieldClientComponent = ({ path, field }) => {
const { value, setValue } = useField({ path })
return (
<div>
<label>{field.label}</label>
<select value={value} onChange={(e) => setValue(e.target.value)}>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
```
### Cell Component (List View)
```typescript
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
admin: {
components: {
Cell: '/components/StatusCell',
},
},
}
```
```tsx
// components/StatusCell.tsx
import type { SelectFieldCellComponent } from 'payload'
export const StatusCell: SelectFieldCellComponent = ({ data, cellData }) => {
const isPublished = cellData === 'published'
return (
<span
style={{
color: isPublished ? 'green' : 'orange',
fontWeight: 'bold',
}}
>
{cellData}
</span>
)
}
```
### UI Field (Presentational Only)
Special field type for adding custom UI without affecting data:
```typescript
{
name: 'refundButton',
type: 'ui',
admin: {
components: {
Field: '/components/RefundButton',
},
},
}
```
```tsx
// components/RefundButton.tsx
'use client'
import { useDocumentInfo } from '@payloadcms/ui'
export default function RefundButton() {
const { id } = useDocumentInfo()
return (
<button
onClick={async () => {
await fetch(`/api/orders/${id}/refund`, { method: 'POST' })
alert('Refund processed')
}}
>
Process Refund
</button>
)
}
```
## Using Hooks
Payload provides many React hooks for Client Components:
```tsx
'use client'
import {
useAuth, // Current user
useConfig, // Payload config (client-safe)
useDocumentInfo, // Current document info (id, slug, etc.)
useField, // Field value and setValue
useForm, // Form state and dispatch
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>
}
```
**Important:** These hooks only work in Client Components within the Admin Panel context.
## Accessing Payload Config
**In Server Components:**
```tsx
async function MyServerComponent({ payload }) {
const { config } = payload
return <div>{config.serverURL}</div>
}
```
**In Client Components:**
```tsx
'use client'
import { useConfig } from '@payloadcms/ui'
export function MyClientComponent() {
const { config } = useConfig() // Client-safe config
return <div>{config.serverURL}</div>
}
```
**Important:** Client Components receive a serializable version of the config (functions, validation, etc. are stripped).
## Field Config Access
**Server Component:**
```tsx
import type { TextFieldServerComponent } from 'payload'
export const MyFieldComponent: TextFieldServerComponent = ({ field }) => {
return <div>Field name: {field.name}</div>
}
```
**Client Component:**
```tsx
'use client'
import type { TextFieldClientComponent } from 'payload'
export const MyFieldComponent: TextFieldClientComponent = ({ clientField }) => {
// clientField has non-serializable props removed
return <div>Field name: {clientField.name}</div>
}
```
## Translations (i18n)
**Server Component:**
```tsx
import { getTranslation } from '@payloadcms/translations'
async function MyServerComponent({ i18n }) {
const translatedTitle = getTranslation(myTranslation, i18n)
return <p>{translatedTitle}</p>
}
```
**Client Component:**
```tsx
'use client'
import { useTranslation } from '@payloadcms/ui'
export function MyClientComponent() {
const { t, i18n } = useTranslation()
return (
<div>
<p>{t('namespace:key', { variable: 'value' })}</p>
<p>Language: {i18n.language}</p>
</div>
)
}
```
## Styling Components
### Using CSS Variables
```tsx
import './styles.scss'
export function MyComponent() {
return <div className="my-component">Custom Component</div>
}
```
```scss
// styles.scss
.my-component {
background-color: var(--theme-elevation-500);
color: var(--theme-text);
padding: var(--base);
border-radius: var(--border-radius-m);
}
```
### Importing Payload SCSS
```scss
@import '~@payloadcms/ui/scss';
.my-component {
@include mid-break {
background-color: var(--theme-elevation-900);
}
}
```
## Common Patterns
### Conditional Field Visibility
```tsx
'use client'
import { useFormFields } from '@payloadcms/ui'
import type { TextFieldClientComponent } from 'payload'
export const ConditionalField: TextFieldClientComponent = ({ path }) => {
const showField = useFormFields(([fields]) => fields.enableFeature?.value)
if (!showField) return null
return <input type="text" />
}
```
### Loading Data from API
```tsx
'use client'
import { useState, useEffect } from 'react'
export function DataLoader() {
const [data, setData] = useState(null)
useEffect(() => {
fetch('/api/custom-data')
.then((res) => res.json())
.then(setData)
}, [])
return <div>{JSON.stringify(data)}</div>
}
```
### Using Local API in Server Components
```tsx
import type { Payload } from 'payload'
async function RelatedPosts({ payload, id }: { payload: Payload; id: string }) {
const post = await payload.findByID({
collection: 'posts',
id,
depth: 0,
})
const related = await payload.find({
collection: 'posts',
where: {
category: { equals: post.category },
id: { not_equals: id },
},
limit: 5,
})
return (
<div>
<h3>Related Posts</h3>
<ul>
{related.docs.map((doc) => (
<li key={doc.id}>{doc.title}</li>
))}
</ul>
</div>
)
}
export default RelatedPosts
```
## Performance Best Practices
### 1. Minimize Client Bundle Size
```tsx
// ❌ BAD: Imports entire package
'use client'
import { Button } from '@payloadcms/ui'
// ✅ GOOD: Tree-shakeable import for frontend
import { Button } from '@payloadcms/ui/elements/Button'
```
**Rule:** In Admin Panel UI, import from `@payloadcms/ui`. In frontend code, use specific paths.
### 2. Optimize Re-renders
```tsx
// ❌ BAD: Re-renders on every form change
'use client'
import { useForm } from '@payloadcms/ui'
export function MyComponent() {
const { fields } = useForm()
// Re-renders on ANY field change
}
// ✅ GOOD: Only re-renders when specific field changes
;('use client')
import { useFormFields } from '@payloadcms/ui'
export function MyComponent({ path }) {
const value = useFormFields(([fields]) => fields[path])
// Only re-renders when this field changes
}
```
### 3. Use Server Components When Possible
```tsx
// ✅ GOOD: No JavaScript sent to client
async function PostCount({ payload }) {
const { totalDocs } = await payload.find({
collection: 'posts',
limit: 0,
})
return <p>{totalDocs} posts</p>
}
// Only use client components when you need:
// - State (useState, useReducer)
// - Effects (useEffect)
// - Event handlers (onClick, onChange)
// - Browser APIs (localStorage, window)
```
### 4. React Best Practices
- Use React.memo() for expensive components
- Implement proper key props in lists
- Avoid inline function definitions in renders
- Use Suspense boundaries for async operations
## Import Map
Payload generates an import map at `app/(payload)/admin/importMap.js` that resolves all component paths.
**Regenerate manually:**
```bash
payload generate:importmap
```
**Override location:**
```typescript
export default buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname, 'src'),
importMapFile: path.resolve(dirname, 'app', 'custom-import-map.js'),
},
},
})
```
## Type Safety
Use Payload's TypeScript types for components:
```tsx
import type {
TextFieldServerComponent,
TextFieldClientComponent,
TextFieldCellComponent,
} from 'payload'
export const MyFieldComponent: TextFieldServerComponent = (props) => {
// Fully typed props
}
```
## Troubleshooting
### "useConfig is undefined" or similar hook errors
**Cause:** Dependency version mismatch between Payload packages.
**Solution:** Pin all `@payloadcms/*` packages to the exact same version:
```json
{
"dependencies": {
"payload": "3.0.0",
"@payloadcms/ui": "3.0.0",
"@payloadcms/richtext-lexical": "3.0.0"
}
}
```
### Component not loading
1. Check file path is correct (relative to baseDir)
2. Verify named export syntax: `/path/to/file#ExportName`
3. Run `payload generate:importmap` to regenerate
4. Check for TypeScript errors in component file
## Resources
- [Custom Components Docs](https://payloadcms.com/docs/custom-components/overview)
- [Root Components](https://payloadcms.com/docs/custom-components/root-components)
- [Custom Views](https://payloadcms.com/docs/custom-components/custom-views)
- [React Hooks](https://payloadcms.com/docs/admin/react-hooks)
- [Custom CSS](https://payloadcms.com/docs/admin/customizing-css)

236
.cursor/rules/endpoints.md Normal file
View File

@ -0,0 +1,236 @@
---
title: Custom Endpoints
description: Custom REST API endpoints with authentication and helpers
tags: [payload, endpoints, api, routes, webhooks]
---
# Payload Custom Endpoints
## Basic Endpoint Pattern
Custom endpoints are **not authenticated by default**. Always check `req.user`.
```typescript
import { APIError } from 'payload'
import type { Endpoint } from 'payload'
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
```typescript
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)
},
}
```
## Request Body Handling
```typescript
// Manual JSON parsing
export const createEndpoint: Endpoint = {
path: '/create',
method: 'post',
handler: async (req) => {
const data = await req.json()
const result = await req.payload.create({
collection: 'posts',
data,
})
return Response.json(result)
},
}
// Using helper (handles JSON + files)
import { addDataAndFileToRequest } from 'payload'
export const uploadEndpoint: Endpoint = {
path: '/upload',
method: 'post',
handler: async (req) => {
await addDataAndFileToRequest(req)
// req.data contains parsed body
// req.file contains uploaded file (if multipart)
const result = await req.payload.create({
collection: 'media',
data: req.data,
file: req.file,
})
return Response.json(result)
},
}
```
## Query Parameters
```typescript
export const searchEndpoint: Endpoint = {
path: '/search',
method: 'get',
handler: async (req) => {
const url = new URL(req.url)
const query = url.searchParams.get('q')
const limit = parseInt(url.searchParams.get('limit') || '10')
const results = await req.payload.find({
collection: 'posts',
where: {
title: {
contains: query,
},
},
limit,
})
return Response.json(results)
},
}
```
## CORS Headers
```typescript
import { headersWithCors } from 'payload'
export const corsEndpoint: Endpoint = {
path: '/public-data',
method: 'get',
handler: async (req) => {
const data = await fetchPublicData()
return Response.json(data, {
headers: headersWithCors({
headers: new Headers(),
req,
}),
})
},
}
```
## Error Handling
```typescript
import { APIError } from 'payload'
export const validateEndpoint: Endpoint = {
path: '/validate',
method: 'post',
handler: async (req) => {
const data = await req.json()
if (!data.email) {
throw new APIError('Email is required', 400)
}
return Response.json({ valid: true })
},
}
```
## Endpoint Placement
### Collection Endpoints
Mounted at `/api/{collection-slug}/{path}`.
```typescript
export const Orders: CollectionConfig = {
slug: 'orders',
endpoints: [
{
path: '/:id/tracking',
method: 'get',
handler: async (req) => {
// Available at: /api/orders/:id/tracking
const orderId = req.routeParams.id
return Response.json({ orderId })
},
},
],
}
```
### Global Endpoints
Mounted at `/api/globals/{global-slug}/{path}`.
```typescript
export const Settings: GlobalConfig = {
slug: 'settings',
endpoints: [
{
path: '/clear-cache',
method: 'post',
handler: async (req) => {
// Available at: /api/globals/settings/clear-cache
await clearCache()
return Response.json({ message: 'Cache cleared' })
},
},
],
}
```
### Root Endpoints
Mounted at `/api/{path}`.
```typescript
export default buildConfig({
endpoints: [
{
path: '/hello',
method: 'get',
handler: () => {
// Available at: /api/hello
return Response.json({ message: 'Hello!' })
},
},
],
})
```
## Best Practices
1. **Always check authentication** - Custom endpoints are not authenticated by default
2. **Use `req.payload` for operations** - Ensures access control and hooks execute
3. **Use helpers for common tasks** - `addDataAndFileToRequest`, `headersWithCors`
4. **Throw `APIError` for errors** - Provides consistent error responses
5. **Return Web API `Response`** - Use `Response.json()` for consistent responses
6. **Validate input** - Check required fields, validate types
7. **Log errors** - Use `req.payload.logger` for debugging

View File

@ -0,0 +1,230 @@
---
title: Field Type Guards
description: Runtime field type checking and safe type narrowing
tags: [payload, typescript, type-guards, fields]
---
# Payload Field Type Guards
Type guards for runtime field type checking and safe type narrowing.
## Most Common Guards
### fieldAffectsData
**Most commonly used guard.** Checks if field stores data (has name and is not UI-only).
```typescript
import { fieldAffectsData } from 'payload'
function generateSchema(fields: Field[]) {
fields.forEach((field) => {
if (fieldAffectsData(field)) {
// Safe to access field.name
schema[field.name] = getFieldType(field)
}
})
}
// Filter data fields
const dataFields = fields.filter(fieldAffectsData)
```
### fieldHasSubFields
Checks if field contains nested fields (group, array, row, or collapsible).
```typescript
import { fieldHasSubFields } from 'payload'
function traverseFields(fields: Field[]): void {
fields.forEach((field) => {
if (fieldHasSubFields(field)) {
// Safe to access field.fields
traverseFields(field.fields)
}
})
}
```
### fieldIsArrayType
Checks if field type is `'array'`.
```typescript
import { fieldIsArrayType } from 'payload'
if (fieldIsArrayType(field)) {
// field.type === 'array'
console.log(`Min rows: ${field.minRows}`)
console.log(`Max rows: ${field.maxRows}`)
}
```
## Capability Guards
### fieldSupportsMany
Checks if field can have multiple values (select, relationship, or upload with `hasMany`).
```typescript
import { fieldSupportsMany } from 'payload'
if (fieldSupportsMany(field)) {
// field.type is 'select' | 'relationship' | 'upload'
if (field.hasMany) {
console.log('Field accepts multiple values')
}
}
```
### fieldHasMaxDepth
Checks if field is relationship/upload/join with numeric `maxDepth` property.
```typescript
import { fieldHasMaxDepth } from 'payload'
if (fieldHasMaxDepth(field)) {
// field.type is 'upload' | 'relationship' | 'join'
// AND field.maxDepth is number
const remainingDepth = field.maxDepth - currentDepth
}
```
### fieldIsVirtual
Checks if field is virtual (computed or virtual relationship).
```typescript
import { fieldIsVirtual } from 'payload'
if (fieldIsVirtual(field)) {
// field.virtual is truthy
if (typeof field.virtual === 'string') {
console.log(`Virtual path: ${field.virtual}`)
}
}
```
## Type Checking Guards
### fieldIsBlockType
```typescript
import { fieldIsBlockType } from 'payload'
if (fieldIsBlockType(field)) {
// field.type === 'blocks'
field.blocks.forEach((block) => {
console.log(`Block: ${block.slug}`)
})
}
```
### fieldIsGroupType
```typescript
import { fieldIsGroupType } from 'payload'
if (fieldIsGroupType(field)) {
// field.type === 'group'
console.log(`Interface: ${field.interfaceName}`)
}
```
### fieldIsPresentationalOnly
```typescript
import { fieldIsPresentationalOnly } from 'payload'
if (fieldIsPresentationalOnly(field)) {
// field.type === 'ui'
// Skip in data operations, GraphQL schema, etc.
return
}
```
## Common Patterns
### Recursive Field Traversal
```typescript
import { fieldAffectsData, fieldHasSubFields } from 'payload'
function traverseFields(fields: Field[], callback: (field: Field) => void) {
fields.forEach((field) => {
if (fieldAffectsData(field)) {
callback(field)
}
if (fieldHasSubFields(field)) {
traverseFields(field.fields, callback)
}
})
}
```
### Filter Data-Bearing Fields
```typescript
import { fieldAffectsData, fieldIsPresentationalOnly, fieldIsHiddenOrDisabled } from 'payload'
const dataFields = fields.filter(
(field) =>
fieldAffectsData(field) && !fieldIsPresentationalOnly(field) && !fieldIsHiddenOrDisabled(field),
)
```
### Container Type Switching
```typescript
import { fieldIsArrayType, fieldIsBlockType, fieldHasSubFields } from 'payload'
if (fieldIsArrayType(field)) {
// Handle array-specific logic
} else if (fieldIsBlockType(field)) {
// Handle blocks-specific logic
} else if (fieldHasSubFields(field)) {
// Handle group/row/collapsible
}
```
### Safe Property Access
```typescript
import { fieldSupportsMany, fieldHasMaxDepth } from 'payload'
// With guard - safe access
if (fieldSupportsMany(field) && field.hasMany) {
console.log('Multiple values supported')
}
if (fieldHasMaxDepth(field)) {
const depth = field.maxDepth // TypeScript knows this is number
}
```
## All Available Guards
| Type Guard | Checks For | Use When |
| --------------------------- | --------------------------------- | ---------------------------------------- |
| `fieldAffectsData` | Field stores data (has name) | Need to access field data or name |
| `fieldHasSubFields` | Field contains nested fields | Recursively traverse fields |
| `fieldIsArrayType` | Field is array type | Distinguish arrays from other containers |
| `fieldIsBlockType` | Field is blocks type | Handle blocks-specific logic |
| `fieldIsGroupType` | Field is group type | Handle group-specific logic |
| `fieldSupportsMany` | Field can have multiple values | Check for `hasMany` support |
| `fieldHasMaxDepth` | Field supports depth control | Control relationship/upload/join depth |
| `fieldIsPresentationalOnly` | Field is UI-only | Exclude from data operations |
| `fieldIsSidebar` | Field positioned in sidebar | Separate sidebar rendering |
| `fieldIsID` | Field name is 'id' | Special ID field handling |
| `fieldIsHiddenOrDisabled` | Field is hidden or disabled | Filter from UI operations |
| `fieldShouldBeLocalized` | Field needs localization | Proper locale table checks |
| `fieldIsVirtual` | Field is virtual | Skip in database transforms |
| `tabHasName` | Tab is named (stores data) | Distinguish named vs unnamed tabs |
| `groupHasName` | Group is named (stores data) | Distinguish named vs unnamed groups |
| `optionIsObject` | Option is `{label, value}` | Access option properties safely |
| `optionsAreObjects` | All options are objects | Batch option processing |
| `optionIsValue` | Option is string value | Handle string options |
| `valueIsValueWithRelation` | Value is polymorphic relationship | Handle polymorphic relationships |

317
.cursor/rules/fields.md Normal file
View File

@ -0,0 +1,317 @@
---
title: Fields
description: Field types, patterns, and configurations
tags: [payload, fields, validation, conditional]
---
# Payload CMS Fields
## Common Field 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}`],
},
}
```
## Field Types
### Text Field
```typescript
{
name: 'title',
type: 'text',
required: true,
unique: true,
minLength: 5,
maxLength: 100,
index: true,
localized: true,
defaultValue: 'Default Title',
validate: (value) => Boolean(value) || 'Required',
admin: {
placeholder: 'Enter title...',
position: 'sidebar',
condition: (data) => data.showTitle === true,
},
}
```
### Rich Text (Lexical)
```typescript
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { HeadingFeature, LinkFeature } from '@payloadcms/richtext-lexical'
{
name: 'content',
type: 'richText',
required: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
HeadingFeature({
enabledHeadingSizes: ['h1', 'h2', 'h3'],
}),
LinkFeature({
enabledCollections: ['posts', 'pages'],
}),
],
}),
}
```
### Relationship
```typescript
// Single relationship
{
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
maxDepth: 2,
}
// Multiple relationships (hasMany)
{
name: 'categories',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
filterOptions: {
active: { equals: true },
},
}
// Polymorphic relationship
{
name: 'relatedContent',
type: 'relationship',
relationTo: ['posts', 'pages'],
hasMany: true,
}
```
### Array
```typescript
{
name: 'slides',
type: 'array',
minRows: 2,
maxRows: 10,
labels: {
singular: 'Slide',
plural: 'Slides',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
],
admin: {
initCollapsed: true,
},
}
```
### Blocks
```typescript
import type { Block } from 'payload'
const HeroBlock: Block = {
slug: 'hero',
interfaceName: 'HeroBlock',
fields: [
{
name: 'heading',
type: 'text',
required: true,
},
{
name: 'background',
type: 'upload',
relationTo: 'media',
},
],
}
const ContentBlock: Block = {
slug: 'content',
fields: [
{
name: 'text',
type: 'richText',
},
],
}
{
name: 'layout',
type: 'blocks',
blocks: [HeroBlock, ContentBlock],
}
```
### Select
```typescript
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
required: true,
}
// Multiple select
{
name: 'tags',
type: 'select',
hasMany: true,
options: ['tech', 'news', 'sports'],
}
```
### Upload
```typescript
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
required: true,
filterOptions: {
mimeType: { contains: 'image' },
},
}
```
### Point (Geolocation)
```typescript
{
name: 'location',
type: 'point',
label: 'Location',
required: true,
}
// Query by distance
const nearbyLocations = await payload.find({
collection: 'stores',
where: {
location: {
near: [10, 20], // [longitude, latitude]
maxDistance: 5000, // in meters
minDistance: 1000,
},
},
})
```
### Join Fields (Reverse Relationships)
```typescript
// From Users collection - show user's orders
{
name: 'orders',
type: 'join',
collection: 'orders',
on: 'customer', // The field in 'orders' that references this user
}
```
### Tabs & Groups
```typescript
// Tabs
{
type: 'tabs',
tabs: [
{
label: 'Content',
fields: [
{ name: 'title', type: 'text' },
{ name: 'body', type: 'richText' },
],
},
{
label: 'SEO',
fields: [
{ name: 'metaTitle', type: 'text' },
{ name: 'metaDescription', type: 'textarea' },
],
},
],
}
// Group (named)
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
],
}
```
## Validation
```typescript
{
name: 'email',
type: 'email',
validate: (value, { operation, data, siblingData }) => {
if (operation === 'create' && !value) {
return 'Email is required'
}
if (value && !value.includes('@')) {
return 'Invalid email format'
}
return true
},
}
```

175
.cursor/rules/hooks.md Normal file
View File

@ -0,0 +1,175 @@
---
title: Hooks
description: Collection hooks, field hooks, and context patterns
tags: [payload, hooks, lifecycle, context]
---
# Payload CMS Hooks
## Collection Hooks
```typescript
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
})
},
],
},
}
```
## Field Hooks
```typescript
import type { FieldHook } from 'payload'
const beforeValidateHook: FieldHook = ({ value }) => {
return value.trim().toLowerCase()
}
const afterReadHook: FieldHook = ({ value, req }) => {
// Hide email from non-admins
if (!req.user?.roles?.includes('admin')) {
return value.replace(/(.{2})(.*)(@.*)/, '$1***$3')
}
return value
}
{
name: 'email',
type: 'email',
hooks: {
beforeValidate: [beforeValidateHook],
afterRead: [afterReadHook],
},
}
```
## Hook Context
Share data between hooks or control hook behavior using request context:
```typescript
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
beforeChange: [
async ({ context }) => {
context.expensiveData = await fetchExpensiveData()
},
],
afterChange: [
async ({ context, doc }) => {
// Reuse from previous hook
await processData(doc, context.expensiveData)
},
],
},
}
```
## Next.js Revalidation Pattern
```typescript
import type { CollectionAfterChangeHook } from 'payload'
import { revalidatePath } from 'next/cache'
export const revalidatePage: CollectionAfterChangeHook = ({
doc,
previousDoc,
req: { payload, context },
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const path = doc.slug === 'home' ? '/' : `/${doc.slug}`
payload.logger.info(`Revalidating page at path: ${path}`)
revalidatePath(path)
}
// Revalidate old path if unpublished
if (previousDoc?._status === 'published' && doc._status !== 'published') {
const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}`
revalidatePath(oldPath)
}
}
return doc
}
```
## Date Field Auto-Set
```typescript
{
name: 'publishedOn',
type: 'date',
hooks: {
beforeChange: [
({ siblingData, value }) => {
if (siblingData._status === 'published' && !value) {
return new Date()
}
return value
},
],
},
}
```
## Best Practices
- Use `beforeValidate` for data formatting
- Use `beforeChange` for business logic
- Use `afterChange` for side effects
- Use `afterRead` for computed fields
- Store expensive operations in `context`
- Pass `req` to nested operations for transaction safety
- Use context flags to prevent infinite loops

View File

@ -0,0 +1,126 @@
---
title: Payload CMS Overview
description: Core principles and quick reference for Payload CMS development
tags: [payload, overview, quickstart]
---
# 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
## 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
```
## 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,
}),
})
```
## 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>
}
```
## Quick Reference
| Task | Solution |
| --------------------- | ---------------------------------- |
| Auto-generate slugs | `slugField()` |
| Restrict by user | Access control with query |
| Local API user ops | `user` + `overrideAccess: false` |
| Draft/publish | `versions: { drafts: true }` |
| Computed fields | `virtual: true` with afterRead |
| Conditional fields | `admin.condition` |
| Custom validation | `validate` function |
| Filter relationships | `filterOptions` on field |
| Select fields | `select` parameter |
| Auto-set dates | beforeChange hook |
| Prevent loops | `req.context` check |
| Cascading deletes | beforeDelete hook |
| Geospatial queries | `point` field with `near`/`within` |
| Reverse relationships | `join` field type |
| Query relationships | Nested property syntax |
| Complex queries | AND/OR logic |
| Transactions | Pass `req` to operations |
| Background jobs | Jobs queue with tasks |
| Custom routes | Collection custom endpoints |
| Cloud storage | Storage adapter plugins |
| Multi-language | `localization` + `localized: true` |
## 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

View File

@ -0,0 +1,323 @@
---
title: Plugin Development
description: Creating Payload CMS plugins with TypeScript patterns
tags: [payload, plugins, architecture, patterns]
---
# Payload Plugin Development
## Plugin Architecture
Plugins are functions that receive configuration options and return a function that transforms the Payload config:
```typescript
import type { Config, Plugin } from 'payload'
interface MyPluginConfig {
enabled?: boolean
collections?: string[]
}
export const myPlugin =
(options: MyPluginConfig): Plugin =>
(config: Config): Config => ({
...config,
// Transform config here
})
```
**Key Pattern:** Double arrow function (currying)
- First function: Accepts plugin options, returns plugin function
- Second function: Accepts Payload config, returns modified config
## Adding Fields to Collections
```typescript
export const seoPlugin =
(options: { collections?: string[] }): Plugin =>
(config: Config): Config => {
const seoFields: Field[] = [
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
],
},
]
return {
...config,
collections: config.collections?.map((collection) => {
if (options.collections?.includes(collection.slug)) {
return {
...collection,
fields: [...(collection.fields || []), ...seoFields],
}
}
return collection
}),
}
}
```
## Adding New Collections
```typescript
export const redirectsPlugin =
(options: { overrides?: Partial<CollectionConfig> }): Plugin =>
(config: Config): Config => {
const redirectsCollection: CollectionConfig = {
slug: 'redirects',
access: { read: () => true },
fields: [
{ name: 'from', type: 'text', required: true, unique: true },
{ name: 'to', type: 'text', required: true },
],
...options.overrides,
}
return {
...config,
collections: [...(config.collections || []), redirectsCollection],
}
}
```
## Adding Hooks
```typescript
const resaveChildrenHook: CollectionAfterChangeHook = async ({ doc, req, operation }) => {
if (operation === 'update') {
const children = await req.payload.find({
collection: 'pages',
where: { parent: { equals: doc.id } },
})
for (const child of children.docs) {
await req.payload.update({
collection: 'pages',
id: child.id,
data: child,
})
}
}
return doc
}
export const nestedDocsPlugin =
(options: { collections: string[] }): Plugin =>
(config: Config): Config => ({
...config,
collections: (config.collections || []).map((collection) => {
if (options.collections.includes(collection.slug)) {
return {
...collection,
hooks: {
...(collection.hooks || {}),
afterChange: [resaveChildrenHook, ...(collection.hooks?.afterChange || [])],
},
}
}
return collection
}),
})
```
## Adding Root-Level Endpoints
```typescript
export const seoPlugin =
(options: { generateTitle?: (doc: any) => string }): Plugin =>
(config: Config): Config => {
const generateTitleEndpoint: Endpoint = {
path: '/plugin-seo/generate-title',
method: 'post',
handler: async (req) => {
const data = await req.json?.()
const result = options.generateTitle ? options.generateTitle(data.doc) : ''
return Response.json({ result })
},
}
return {
...config,
endpoints: [...(config.endpoints ?? []), generateTitleEndpoint],
}
}
```
## Field Overrides with Defaults
```typescript
type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
interface PluginConfig {
collections?: string[]
fields?: FieldsOverride
}
export const myPlugin =
(options: PluginConfig): Plugin =>
(config: Config): Config => {
const defaultFields: Field[] = [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
]
const fields =
options.fields && typeof options.fields === 'function'
? options.fields({ defaultFields })
: defaultFields
return {
...config,
collections: config.collections?.map((collection) => {
if (options.collections?.includes(collection.slug)) {
return {
...collection,
fields: [...(collection.fields || []), ...fields],
}
}
return collection
}),
}
}
```
## Disable Plugin Pattern
```typescript
interface PluginConfig {
disabled?: boolean
collections?: string[]
}
export const myPlugin =
(options: PluginConfig): Plugin =>
(config: Config): Config => {
// Always add collections/fields for database schema consistency
if (!config.collections) {
config.collections = []
}
config.collections.push({
slug: 'plugin-collection',
fields: [{ name: 'title', type: 'text' }],
})
// If disabled, return early but keep schema changes
if (options.disabled) {
return config
}
// Add endpoints, hooks, components only when enabled
config.endpoints = [
...(config.endpoints ?? []),
{
path: '/my-endpoint',
method: 'get',
handler: async () => Response.json({ message: 'Hello' }),
},
]
return config
}
```
## Admin Components
```typescript
export const myPlugin =
(options: PluginConfig): Plugin =>
(config: Config): Config => {
if (!config.admin) config.admin = {}
if (!config.admin.components) config.admin.components = {}
if (!config.admin.components.beforeDashboard) {
config.admin.components.beforeDashboard = []
}
// Add client component
config.admin.components.beforeDashboard.push('my-plugin-name/client#BeforeDashboardClient')
// Add server component (RSC)
config.admin.components.beforeDashboard.push('my-plugin-name/rsc#BeforeDashboardServer')
return config
}
```
## onInit Hook
```typescript
export const myPlugin =
(options: PluginConfig): Plugin =>
(config: Config): Config => {
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
// IMPORTANT: Call existing onInit first
if (incomingOnInit) await incomingOnInit(payload)
// Plugin initialization
payload.logger.info('Plugin initialized')
// Example: Seed data
const { totalDocs } = await payload.count({
collection: 'plugin-collection',
where: { id: { equals: 'seeded-by-plugin' } },
})
if (totalDocs === 0) {
await payload.create({
collection: 'plugin-collection',
data: { id: 'seeded-by-plugin' },
})
}
}
return config
}
```
## Best Practices
### Preserve Existing Config
```typescript
// ✅ Good
collections: [...(config.collections || []), newCollection]
// ❌ Bad
collections: [newCollection]
```
### Respect User Overrides
```typescript
const collection: CollectionConfig = {
slug: 'redirects',
fields: defaultFields,
...options.overrides, // User overrides last
}
```
### Hook Composition
```typescript
hooks: {
...collection.hooks,
afterChange: [
myHook,
...(collection.hooks?.afterChange || []),
],
}
```
### Type Safety
```typescript
import type { Config, Plugin, CollectionConfig, Field } from 'payload'
```

223
.cursor/rules/queries.md Normal file
View File

@ -0,0 +1,223 @@
---
title: Queries
description: Local API, REST, and GraphQL query patterns
tags: [payload, queries, local-api, rest, graphql]
---
# Payload CMS Queries
## Query Operators
```typescript
// Equals
{ color: { equals: 'blue' } }
// Not equals
{ status: { not_equals: 'draft' } }
// Greater/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/not in
{ category: { in: ['tech', 'news'] } }
// Exists
{ image: { exists: true } }
// Near (point fields)
{ location: { near: [10, 20, 5000] } } // [lng, lat, maxDistance]
```
## AND/OR Logic
```typescript
{
or: [
{ color: { equals: 'mint' } },
{
and: [
{ color: { equals: 'white' } },
{ featured: { equals: false } },
],
},
],
}
```
## Nested Properties
```typescript
{
'author.role': { equals: 'editor' },
'meta.featured': { exists: true },
}
```
## Local API
```typescript
// Find documents
const posts = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
'author.name': { contains: 'john' },
},
depth: 2, // Populate relationships
limit: 10,
page: 1,
sort: '-createdAt',
locale: 'en',
select: {
title: true,
author: true,
},
})
// Find by ID
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 2,
})
// Create
const post = 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',
})
// Count
const count = await payload.count({
collection: 'posts',
where: {
status: { equals: 'published' },
},
})
```
## Access Control in Local API
**CRITICAL**: Local API bypasses access control by default (`overrideAccess: true`).
```typescript
// ❌ WRONG: User is passed but access control is bypassed
const posts = await payload.find({
collection: 'posts',
user: currentUser,
// Result: Operation runs with ADMIN privileges
})
// ✅ CORRECT: Respects user's access control permissions
const posts = await payload.find({
collection: 'posts',
user: currentUser,
overrideAccess: false, // Required to enforce access control
})
// Administrative operation (intentionally bypass access control)
const allPosts = await payload.find({
collection: 'posts',
// No user parameter, overrideAccess defaults to true
})
```
**When to use `overrideAccess: false`:**
- Performing operations on behalf of a user
- Testing access control logic
- API routes that should respect user permissions
## REST API
```typescript
import { stringify } from 'qs-esm'
const query = {
status: { equals: 'published' },
}
const queryString = stringify(
{
where: query,
depth: 2,
limit: 10,
},
{ addQueryPrefix: true },
)
const response = await fetch(`https://api.example.com/api/posts${queryString}`)
const data = await response.json()
```
### REST Endpoints
```
GET /api/{collection} - Find documents
GET /api/{collection}/{id} - Find by ID
POST /api/{collection} - Create
PATCH /api/{collection}/{id} - Update
DELETE /api/{collection}/{id} - Delete
GET /api/{collection}/count - Count documents
GET /api/globals/{slug} - Get global
POST /api/globals/{slug} - Update global
```
## GraphQL
```graphql
query {
Posts(where: { status: { equals: published } }, limit: 10, sort: "-createdAt") {
docs {
id
title
author {
name
}
}
totalDocs
hasNextPage
}
}
mutation {
createPost(data: { title: "New Post", status: draft }) {
id
title
}
}
```
## Performance Best Practices
- Set `maxDepth` on relationships to prevent over-fetching
- Use `select` to limit returned fields
- Index frequently queried fields
- Use `virtual` fields for computed data
- Cache expensive operations in hook `context`

View File

@ -0,0 +1,122 @@
---
title: Critical Security Patterns
description: The three most important security patterns in Payload CMS
tags: [payload, security, critical, access-control, transactions, hooks]
priority: high
---
# CRITICAL SECURITY PATTERNS
These are the three most critical security patterns that MUST be followed in every Payload CMS project.
## 1. Local API Access Control (MOST IMPORTANT)
**By default, Local API operations bypass ALL access control**, even when passing a user.
```typescript
// ❌ SECURITY BUG: Passes user but ignores their permissions
await payload.find({
collection: 'posts',
user: someUser, // Access control is BYPASSED!
})
// ✅ SECURE: Actually enforces the user's permissions
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // REQUIRED for access control
})
// ✅ Administrative operation (intentional bypass)
await payload.find({
collection: 'posts',
// No user, overrideAccess defaults to true
})
```
**When to use each:**
- `overrideAccess: true` (default) - Server-side operations you trust (cron jobs, system tasks)
- `overrideAccess: false` - When operating on behalf of a user (API routes, webhooks)
**Rule**: When passing `user` to Local API, ALWAYS set `overrideAccess: false`
## 2. Transaction Safety in Hooks
**Nested operations in hooks without `req` break transaction atomicity.**
```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
})
},
]
}
```
**Why This Matters:**
- **MongoDB (with replica sets)**: Creates atomic session across operations
- **PostgreSQL**: All operations use same Drizzle transaction
- **SQLite (with transactions enabled)**: Ensures rollback on errors
- **Without req**: Each operation runs independently, breaking atomicity
**Rule**: ALWAYS pass `req` to nested operations in hooks
## 3. Prevent Infinite Hook Loops
**Hooks triggering operations that trigger the same hooks create infinite 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,
})
},
]
}
```
**Rule**: Use `req.context` flags to prevent hook loops

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
DATABASE_URL=mongodb://127.0.0.1/your-database-name
PAYLOAD_SECRET=YOUR_SECRET_HERE

50
.gitignore vendored Normal file
View File

@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
/.idea/*
!/.idea/runConfigurations
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
/media
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

1
.npmrc Normal file
View File

@ -0,0 +1 @@
legacy-peer-deps=true

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
}

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

24
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,24 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithChrome",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
},
"cwd": "${workspaceFolder}"
}
]
}

40
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,40 @@
{
"npm.packageManager": "pnpm",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"editor.formatOnSaveMode": "file",
"typescript.tsdk": "node_modules/typescript/lib",
"[javascript][typescript][typescriptreact]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
}

1
.yarnrc Normal file
View File

@ -0,0 +1 @@
--install.ignore-engines true

1141
AGENTS.md Normal file

File diff suppressed because it is too large Load Diff

71
Dockerfile Normal file
View File

@ -0,0 +1,71 @@
# To use this Dockerfile, you have to set `output: 'standalone'` in your next.config.mjs file.
# From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
FROM node:22.17.0-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Remove this line if you do not have this folder
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js

67
README.md Normal file
View File

@ -0,0 +1,67 @@
# Payload Blank Template
This template comes configured with the bare minimum to get started on anything you need.
## Quick start
This template can be deployed directly from our Cloud hosting and it will setup MongoDB and cloud S3 object storage for media.
## Quick Start - local setup
To spin up this template locally, follow these steps:
### Clone
After you click the `Deploy` button above, you'll want to have standalone copy of this repo on your machine. If you've already cloned this repo, skip to [Development](#development).
### Development
1. First [clone the repo](#clone) if you have not done so already
2. `cd my-project && cp .env.example .env` to copy the example environment variables. You'll need to add the `MONGODB_URL` from your Cloud project to your `.env` if you want to use S3 storage and the MongoDB database that was created for you.
3. `pnpm install && pnpm dev` to install dependencies and start the dev server
4. open `http://localhost:3000` to open the app in your browser
That's it! Changes made in `./src` will be reflected in your app. Follow the on-screen instructions to login and create your first admin user. Then check out [Production](#production) once you're ready to build and serve your app, and [Deployment](#deployment) when you're ready to go live.
#### Docker (Optional)
If you prefer to use Docker for local development instead of a local MongoDB instance, the provided docker-compose.yml file can be used.
To do so, follow these steps:
- Modify the `MONGODB_URL` in your `.env` file to `mongodb://127.0.0.1/<dbname>`
- Modify the `docker-compose.yml` file's `MONGODB_URL` to match the above `<dbname>`
- Run `docker-compose up` to start the database, optionally pass `-d` to run in the background.
## How it works
The Payload config is tailored specifically to the needs of most websites. It is pre-configured in the following ways:
### Collections
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend this functionality.
- #### Users (Authentication)
Users are auth-enabled collections that have access to the admin panel.
For additional help, see the official [Auth Example](https://github.com/payloadcms/payload/tree/main/examples/auth) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs.
- #### Media
This is the uploads enabled collection. It features pre-configured sizes, focal point and manual resizing to help you manage your pictures.
### Docker
Alternatively, you can use [Docker](https://www.docker.com) to spin up this template locally. To do so, follow these steps:
1. Follow [steps 1 and 2 from above](#development), the docker-compose file will automatically use the `.env` file in your project root
1. Next run `docker-compose up`
1. Follow [steps 4 and 5 from above](#development) to login and create your first admin user
That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

43
docker-compose.yml Normal file
View File

@ -0,0 +1,43 @@
version: '3'
services:
payload:
image: node:18-alpine
ports:
- '3000:3000'
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
depends_on:
- mongo
# - postgres
env_file:
- .env
# Ensure your DATABASE_URL uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name
mongo:
image: mongo:latest
ports:
- '27017:27017'
command:
- --storageEngine=wiredTiger
volumes:
- data:/data/db
logging:
driver: none
# Uncomment the following to use postgres
# postgres:
# restart: always
# image: postgres:latest
# volumes:
# - pgdata:/var/lib/postgresql/data
# ports:
# - "5432:5432"
volumes:
data:
# pgdata:
node_modules:

38
eslint.config.mjs Normal file
View File

@ -0,0 +1,38 @@
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
})
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
rules: {
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
vars: 'all',
args: 'after-used',
ignoreRestSiblings: false,
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^(_|ignore)',
},
],
},
},
{
ignores: ['.next/'],
},
]
export default eslintConfig

17
next.config.mjs Normal file
View File

@ -0,0 +1,17 @@
import { withPayload } from '@payloadcms/next/withPayload'
/** @type {import('next').NextConfig} */
const nextConfig = {
// Your Next.js config here
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

63
package.json Normal file
View File

@ -0,0 +1,63 @@
{
"name": "gb-payload",
"version": "1.0.0",
"description": "A blank template to get started with Payload 3.0",
"license": "MIT",
"type": "module",
"scripts": {
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"test": "pnpm run test:int && pnpm run test:e2e",
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
},
"dependencies": {
"@payloadcms/next": "3.75.0",
"@payloadcms/richtext-lexical": "3.75.0",
"@payloadcms/ui": "3.75.0",
"cross-env": "^7.0.3",
"dotenv": "16.4.7",
"graphql": "^16.8.1",
"next": "15.4.11",
"payload": "3.75.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"sharp": "0.34.2",
"@payloadcms/db-postgres": "3.75.0"
},
"devDependencies": {
"@playwright/test": "1.56.1",
"@testing-library/react": "16.3.0",
"@types/node": "22.19.9",
"@types/react": "19.2.9",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "4.5.2",
"eslint": "^9.16.0",
"eslint-config-next": "15.4.11",
"jsdom": "28.0.0",
"playwright": "1.56.1",
"playwright-core": "1.56.1",
"prettier": "^3.4.2",
"tsx": "4.21.0",
"typescript": "5.7.3",
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",
"pnpm": "^9 || ^10"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"esbuild",
"unrs-resolver"
]
}
}

41
playwright.config.ts Normal file
View File

@ -0,0 +1,41 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import 'dotenv/config'
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests/e2e',
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
],
webServer: {
command: 'pnpm dev',
reuseExistingServer: true,
url: 'http://localhost:3000',
},
})

8622
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
import React from 'react'
import './styles.css'
export const metadata = {
description: 'A blank template using Payload in a Next.js app.',
title: 'Payload Blank Template',
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
return (
<html lang="en">
<body>
<main>{children}</main>
</body>
</html>
)
}

View File

@ -0,0 +1,59 @@
import { headers as getHeaders } from 'next/headers.js'
import Image from 'next/image'
import { getPayload } from 'payload'
import React from 'react'
import { fileURLToPath } from 'url'
import config from '@/payload.config'
import './styles.css'
export default async function HomePage() {
const headers = await getHeaders()
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
const { user } = await payload.auth({ headers })
const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
return (
<div className="home">
<div className="content">
<picture>
<source srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg" />
<Image
alt="Payload Logo"
height={65}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg"
width={65}
/>
</picture>
{!user && <h1>Welcome to your new project.</h1>}
{user && <h1>Welcome back, {user.email}</h1>}
<div className="links">
<a
className="admin"
href={payloadConfig.routes.admin}
rel="noopener noreferrer"
target="_blank"
>
Go to admin panel
</a>
<a
className="docs"
href="https://payloadcms.com/docs"
rel="noopener noreferrer"
target="_blank"
>
Documentation
</a>
</div>
</div>
<div className="footer">
<p>Update this page by editing</p>
<a className="codeLink" href={fileURL}>
<code>app/(frontend)/page.tsx</code>
</a>
</div>
</div>
)
}

View File

@ -0,0 +1,164 @@
:root {
--font-mono: 'Roboto Mono', monospace;
}
* {
box-sizing: border-box;
}
html {
font-size: 18px;
line-height: 32px;
background: rgb(0, 0, 0);
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body {
font-family: system-ui;
font-size: 18px;
line-height: 32px;
margin: 0;
color: rgb(1000, 1000, 1000);
@media (max-width: 1024px) {
font-size: 15px;
line-height: 24px;
}
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1 {
margin: 40px 0;
font-size: 64px;
line-height: 70px;
font-weight: bold;
@media (max-width: 1024px) {
margin: 24px 0;
font-size: 42px;
line-height: 42px;
}
@media (max-width: 768px) {
font-size: 38px;
line-height: 38px;
}
@media (max-width: 400px) {
font-size: 32px;
line-height: 32px;
}
}
p {
margin: 24px 0;
@media (max-width: 1024px) {
margin: calc(var(--base) * 0.75) 0;
}
}
a {
color: currentColor;
&:focus {
opacity: 0.8;
outline: none;
}
&:active {
opacity: 0.7;
outline: none;
}
}
svg {
vertical-align: middle;
}
.home {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
height: 100vh;
padding: 45px;
max-width: 1024px;
margin: 0 auto;
overflow: hidden;
@media (max-width: 400px) {
padding: 24px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
h1 {
text-align: center;
}
}
.links {
display: flex;
align-items: center;
gap: 12px;
a {
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.admin {
color: rgb(0, 0, 0);
background: rgb(1000, 1000, 1000);
border: 1px solid rgb(0, 0, 0);
}
.docs {
color: rgb(1000, 1000, 1000);
background: rgb(0, 0, 0);
border: 1px solid rgb(1000, 1000, 1000);
}
}
.footer {
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 1024px) {
flex-direction: column;
gap: 6px;
}
p {
margin: 0;
}
.codeLink {
text-decoration: none;
padding: 0 0.5rem;
background: rgb(60, 60, 60);
border-radius: 4px;
}
}
}

View File

@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound

View File

@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page

View File

@ -0,0 +1,5 @@
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
export const importMap = {
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
}

View File

@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

View File

@ -0,0 +1,31 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

12
src/app/my-route/route.ts Normal file
View File

@ -0,0 +1,12 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export const GET = async (request: Request) => {
const payload = await getPayload({
config: configPromise,
})
return Response.json({
message: 'This is an example of a custom route.',
})
}

16
src/collections/Media.ts Normal file
View File

@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
],
upload: true,
}

13
src/collections/Users.ts Normal file
View File

@ -0,0 +1,13 @@
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
// Email added by default
// Add more fields as needed
],
}

326
src/payload-types.ts Normal file
View File

@ -0,0 +1,326 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
media: Media;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
fallbackLocale: null;
globals: {};
globalsSelect: {};
locale: null;
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: string;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'users';
value: string | User;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

34
src/payload.config.ts Normal file
View File

@ -0,0 +1,34 @@
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Users, Media],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL || '',
},
}),
sharp,
plugins: [],
})

1
test.env Normal file
View File

@ -0,0 +1 @@
NODE_OPTIONS="--no-deprecation --no-experimental-strip-types"

View File

@ -0,0 +1,41 @@
import { test, expect, Page } from '@playwright/test'
import { login } from '../helpers/login'
import { seedTestUser, cleanupTestUser, testUser } from '../helpers/seedUser'
test.describe('Admin Panel', () => {
let page: Page
test.beforeAll(async ({ browser }, testInfo) => {
await seedTestUser()
const context = await browser.newContext()
page = await context.newPage()
await login({ page, user: testUser })
})
test.afterAll(async () => {
await cleanupTestUser()
})
test('can navigate to dashboard', async () => {
await page.goto('http://localhost:3000/admin')
await expect(page).toHaveURL('http://localhost:3000/admin')
const dashboardArtifact = page.locator('span[title="Dashboard"]').first()
await expect(dashboardArtifact).toBeVisible()
})
test('can navigate to list view', async () => {
await page.goto('http://localhost:3000/admin/collections/users')
await expect(page).toHaveURL('http://localhost:3000/admin/collections/users')
const listViewArtifact = page.locator('h1', { hasText: 'Users' }).first()
await expect(listViewArtifact).toBeVisible()
})
test('can navigate to edit view', async () => {
await page.goto('http://localhost:3000/admin/collections/users/create')
await expect(page).toHaveURL(/\/admin\/collections\/users\/[a-zA-Z0-9-_]+/)
const editViewArtifact = page.locator('input[name="email"]')
await expect(editViewArtifact).toBeVisible()
})
})

View File

@ -0,0 +1,20 @@
import { test, expect, Page } from '@playwright/test'
test.describe('Frontend', () => {
let page: Page
test.beforeAll(async ({ browser }, testInfo) => {
const context = await browser.newContext()
page = await context.newPage()
})
test('can go on homepage', async ({ page }) => {
await page.goto('http://localhost:3000')
await expect(page).toHaveTitle(/Payload Blank Template/)
const heading = page.locator('h1').first()
await expect(heading).toHaveText('Welcome to your new project.')
})
})

31
tests/helpers/login.ts Normal file
View File

@ -0,0 +1,31 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
export interface LoginOptions {
page: Page
serverURL?: string
user: {
email: string
password: string
}
}
/**
* Logs the user into the admin panel via the login page.
*/
export async function login({
page,
serverURL = 'http://localhost:3000',
user,
}: LoginOptions): Promise<void> {
await page.goto(`${serverURL}/admin/login`)
await page.fill('#field-email', user.email)
await page.fill('#field-password', user.password)
await page.click('button[type="submit"]')
await page.waitForURL(`${serverURL}/admin`)
const dashboardArtifact = page.locator('span[title="Dashboard"]')
await expect(dashboardArtifact).toBeVisible()
}

46
tests/helpers/seedUser.ts Normal file
View File

@ -0,0 +1,46 @@
import { getPayload } from 'payload'
import config from '../../src/payload.config.js'
export const testUser = {
email: 'dev@payloadcms.com',
password: 'test',
}
/**
* Seeds a test user for e2e admin tests.
*/
export async function seedTestUser(): Promise<void> {
const payload = await getPayload({ config })
// Delete existing test user if any
await payload.delete({
collection: 'users',
where: {
email: {
equals: testUser.email,
},
},
})
// Create fresh test user
await payload.create({
collection: 'users',
data: testUser,
})
}
/**
* Cleans up test user after tests
*/
export async function cleanupTestUser(): Promise<void> {
const payload = await getPayload({ config })
await payload.delete({
collection: 'users',
where: {
email: {
equals: testUser.email,
},
},
})
}

20
tests/int/api.int.spec.ts Normal file
View File

@ -0,0 +1,20 @@
import { getPayload, Payload } from 'payload'
import config from '@/payload.config'
import { describe, it, beforeAll, expect } from 'vitest'
let payload: Payload
describe('API', () => {
beforeAll(async () => {
const payloadConfig = await config
payload = await getPayload({ config: payloadConfig })
})
it('fetches users', async () => {
const users = await payload.find({
collection: 'users',
})
expect(users).toBeDefined()
})
})

44
tsconfig.json Normal file
View File

@ -0,0 +1,44 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
],
"@payload-config": [
"./src/payload.config.ts"
]
},
"target": "ES2022",
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
],
}

12
vitest.config.mts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['tests/int/**/*.int.spec.ts'],
},
})

4
vitest.setup.ts Normal file
View File

@ -0,0 +1,4 @@
// Any setup scripts you might need go here
// Load .env files
import 'dotenv/config'