feat: initial commit

This commit is contained in:
2026-07-08 00:54:30 +02:00
commit 10638127a9
52 changed files with 20041 additions and 0 deletions
+448
View File
@@ -0,0 +1,448 @@
---
name: payload
description: Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.
---
# Payload CMS Application Development
Payload is a Next.js native CMS with TypeScript-first architecture, providing admin panel, database management, REST/GraphQL APIs, authentication, and file storage.
## Quick Reference
| Task | Solution | Details |
| ------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Auto-generate slugs | `slugField()` | [FIELDS.md#slug-field-helper](reference/FIELDS.md#slug-field-helper) |
| Restrict content by user | Access control with query | [ACCESS-CONTROL.md#row-level-security-with-complex-queries](reference/ACCESS-CONTROL.md#row-level-security-with-complex-queries) |
| Local API user ops | `user` + `overrideAccess: false` | [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api) |
| Draft/publish workflow | `versions: { drafts: true }` | [COLLECTIONS.md#versioning--drafts](reference/COLLECTIONS.md#versioning--drafts) |
| Computed fields | `virtual: true` with afterRead | [FIELDS.md#virtual-fields](reference/FIELDS.md#virtual-fields) |
| Conditional fields | `admin.condition` | [FIELDS.md#conditional-fields](reference/FIELDS.md#conditional-fields) |
| Custom field validation | `validate` function | [FIELDS.md#validation](reference/FIELDS.md#validation) |
| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) |
| Select specific fields | `select` parameter | [QUERIES.md#field-selection](reference/QUERIES.md#field-selection) |
| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
| Prevent hook loops | `req.context` check | [HOOKS.md#context](reference/HOOKS.md#context) |
| Cascading deletes | beforeDelete hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
| Geospatial queries | `point` field with `near`/`within` | [FIELDS.md#point-geolocation](reference/FIELDS.md#point-geolocation) |
| Reverse relationships | `join` field type | [FIELDS.md#join-fields](reference/FIELDS.md#join-fields) |
| Next.js revalidation | Context control in afterChange | [HOOKS.md#nextjs-revalidation-with-context-control](reference/HOOKS.md#nextjs-revalidation-with-context-control) |
| Query by relationship | Nested property syntax | [QUERIES.md#nested-properties](reference/QUERIES.md#nested-properties) |
| Complex queries | AND/OR logic | [QUERIES.md#andor-logic](reference/QUERIES.md#andor-logic) |
| Transactions | Pass `req` to operations | [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations) |
| Background jobs | Jobs queue with tasks | [ADVANCED.md#jobs-queue](reference/ADVANCED.md#jobs-queue) |
| Custom API routes | Collection custom endpoints | [ADVANCED.md#custom-endpoints](reference/ADVANCED.md#custom-endpoints) |
| Cloud storage | Storage adapter plugins | [ADAPTERS.md#storage-adapters](reference/ADAPTERS.md#storage-adapters) |
| Multi-language | `localization` config + `localized: true` | [ADVANCED.md#localization](reference/ADVANCED.md#localization) |
| Create plugin | `(options) => (config) => Config` | [PLUGIN-DEVELOPMENT.md#plugin-architecture](reference/PLUGIN-DEVELOPMENT.md#plugin-architecture) |
| Plugin package setup | Package structure with SWC | [PLUGIN-DEVELOPMENT.md#plugin-package-structure](reference/PLUGIN-DEVELOPMENT.md#plugin-package-structure) |
| Add fields to collection | Map collections, spread fields | [PLUGIN-DEVELOPMENT.md#adding-fields-to-collections](reference/PLUGIN-DEVELOPMENT.md#adding-fields-to-collections) |
| Plugin hooks | Preserve existing hooks in array | [PLUGIN-DEVELOPMENT.md#adding-hooks](reference/PLUGIN-DEVELOPMENT.md#adding-hooks) |
| Check field type | Type guard functions | [FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md) |
## Quick Start
```bash
npx create-payload-app@latest my-app
cd my-app
pnpm dev
```
### Minimal Config
```ts
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,
}),
})
```
## Essential Patterns
### Basic Collection
```ts
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,
}
```
For more collection patterns (auth, upload, drafts, live preview), see [COLLECTIONS.md](reference/COLLECTIONS.md).
### Common Fields
```ts
// Text field
{ name: 'title', type: 'text', required: true }
// Relationship
{ name: 'author', type: 'relationship', relationTo: 'users', required: true }
// Rich text
{ name: 'content', type: 'richText', required: true }
// Select
{ name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft' }
// Upload
{ name: 'image', type: 'upload', relationTo: 'media' }
```
For all field types (array, blocks, point, join, virtual, conditional, etc.), see [FIELDS.md](reference/FIELDS.md).
### Hook Example
```ts
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
beforeChange: [
async ({ data, operation }) => {
if (operation === 'create') {
data.slug = slugify(data.title)
}
return data
},
],
},
fields: [{ name: 'title', type: 'text' }],
}
```
For all hook patterns, see [HOOKS.md](reference/HOOKS.md). For access control, see [ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md).
### Access Control with Type Safety
```ts
import type { Access } from 'payload'
import type { User } from '@/payload-types'
// Type-safe access control
export const adminOnly: Access = ({ req }) => {
const user = req.user as User
return user?.roles?.includes('admin') || false
}
// Row-level access control
export const ownPostsOnly: Access = ({ req }) => {
const user = req.user as User
if (!user) return false
if (user.roles?.includes('admin')) return true
return {
author: { equals: user.id },
}
}
```
### Query Example
```ts
// Local API
const posts = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
'author.name': { contains: 'john' },
},
depth: 2,
limit: 10,
sort: '-createdAt',
})
// Query with populated relationships
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 2, // Populates relationships (default is 2)
})
// Returns: { author: { id: "user123", name: "John" } }
// Without depth, relationships return IDs only
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 0,
})
// Returns: { author: "user123" }
```
For all query operators and REST/GraphQL examples, see [QUERIES.md](reference/QUERIES.md).
### Getting Payload Instance
```ts
// 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>
}
```
## Security Pitfalls
### 1. Local API Access Control (CRITICAL)
**By default, Local API operations bypass ALL access control**, even when passing a user.
```ts
// ❌ 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
})
```
**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)
See [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api).
### 2. Transaction Failures in Hooks
**Nested operations in hooks without `req` break transaction atomicity.**
```ts
// ❌ 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
})
},
]
}
```
See [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations).
### 3. Infinite Hook Loops
**Hooks triggering operations that trigger the same hooks create infinite loops.**
```ts
// ❌ 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,
})
},
]
}
```
See [HOOKS.md#context](reference/HOOKS.md#context).
## Project Structure
```txt
src/
├── app/
│ ├── (frontend)/
│ │ └── page.tsx
│ └── (payload)/
│ └── admin/[[...segments]]/page.tsx
├── collections/
│ ├── Posts.ts
│ ├── Media.ts
│ └── Users.ts
├── globals/
│ └── Header.ts
├── components/
│ └── CustomField.tsx
├── hooks/
│ └── slugify.ts
└── payload.config.ts
```
## Type Generation
```ts
// payload.config.ts
export default buildConfig({
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
// ...
})
// Usage
import type { Post, User } from '@/payload-types'
```
## Common Gotchas
1. **Local API bypasses access control** unless you pass `overrideAccess: false`
2. **Missing `req` in nested operations** breaks transaction atomicity
3. **Hook loops** — operations in hooks can re-trigger the same hooks; use `req.context` flags
4. **Field-level access** returns boolean only, no query constraints
5. **Relationship depth** defaults to 2; set `depth: 0` for IDs only
6. **Draft status** — `_status` field is auto-injected when drafts are enabled
7. **Types are stale** until you run `generate:types`
8. **MongoDB transactions** require replica set configuration
9. **SQLite transactions** are disabled by default; enable with `transactionOptions: {}`
10. **Point fields** are not supported in SQLite
## Best Practices
### Security
- Default to restrictive access, gradually add permissions
- Use `overrideAccess: false` when passing `user` to Local API
- Field-level access only returns boolean (no query constraints)
- Never trust client-provided data
- Use `saveToJWT: true` for roles to avoid database lookups
### Performance
- Index frequently queried fields
- Use `select` to limit returned fields
- Set `maxDepth` on relationships to prevent over-fetching
- Prefer query constraints over async operations in access control
- Cache expensive operations in `req.context`
### Data Integrity
- Always pass `req` to nested operations in hooks
- Use context flags to prevent infinite hook loops
- Enable transactions for MongoDB (requires replica set) and Postgres
- Use `beforeValidate` for data formatting
- Use `beforeChange` for business logic
### Type Safety
- Run `generate:types` after schema changes
- Import types from generated `payload-types.ts`
- Type your user object: `import type { User } from '@/payload-types'`
- Use `as const` for field options
- Use field type guards for runtime type checking
### Organization
- Keep collections in separate files
- Extract access control to `access/` directory
- Extract hooks to `hooks/` directory
- Use reusable field factories for common patterns
- Document complex access control with comments
## Reference Documentation
- **[FIELDS.md](reference/FIELDS.md)** - All field types, validation, admin options
- **[FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md)** - Type guards for runtime field type checking and narrowing
- **[COLLECTIONS.md](reference/COLLECTIONS.md)** - Collection configs, auth, upload, drafts, live preview
- **[HOOKS.md](reference/HOOKS.md)** - Collection hooks, field hooks, context patterns
- **[ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md)** - Collection, field, global access control, RBAC, multi-tenant
- **[ACCESS-CONTROL-ADVANCED.md](reference/ACCESS-CONTROL-ADVANCED.md)** - Context-aware, time-based, subscription-based access, factory functions, templates
- **[QUERIES.md](reference/QUERIES.md)** - Query operators, Local/REST/GraphQL APIs
- **[ENDPOINTS.md](reference/ENDPOINTS.md)** - Custom API endpoints: authentication, helpers, request/response patterns
- **[ADAPTERS.md](reference/ADAPTERS.md)** - Database, storage, email adapters, transactions
- **[ADVANCED.md](reference/ADVANCED.md)** - Authentication, jobs, endpoints, components, plugins, localization
- **[PLUGIN-DEVELOPMENT.md](reference/PLUGIN-DEVELOPMENT.md)** - Plugin architecture, monorepo structure, patterns, best practices
## Resources
- llms-full.txt: <https://payloadcms.com/llms-full.txt>
- Docs: <https://payloadcms.com/docs>
- GitHub: <https://github.com/payloadcms/payload>
- Examples: <https://github.com/payloadcms/payload/tree/3.x/examples>
- Templates: <https://github.com/payloadcms/payload/tree/3.x/templates>
@@ -0,0 +1,704 @@
# Payload CMS Access Control - Advanced Patterns
Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates.
## Context-Aware Access Patterns
### Locale-Specific Access
Control access based on user locale for internationalized content.
```ts
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
}
// Usage in collection
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
read: localeSpecificAccess,
},
fields: [{ name: 'title', type: 'text', localized: true }],
}
```
**Source**: `docs/access-control/overview.mdx` (req.locale argument)
### Device-Specific Access
Restrict access based on device type or user agent.
```ts
import type { Access } from 'payload'
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)
}
// Usage
export const MobileContent: CollectionConfig = {
slug: 'mobile-content',
access: {
read: mobileOnlyAccess,
},
fields: [{ name: 'title', type: 'text' }],
}
```
**Source**: Synthesized (headers pattern)
### IP-Based Access
Restrict access from specific IP addresses (requires middleware/proxy headers).
```ts
import type { Access } from 'payload'
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),
},
fields: [{ name: 'content', type: 'richText' }],
}
```
**Note**: Requires your server to pass IP address via headers (common with proxies/load balancers).
**Source**: Synthesized (headers pattern)
## Time-Based Access Patterns
### Today's Records Only
```ts
import type { Access } from 'payload'
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(),
},
}
}
```
**Source**: `test/access-control/config.ts` (query constraint patterns)
### Recent Records (Last N Days)
```ts
import type { Access } from 'payload'
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),
},
fields: [{ name: 'message', type: 'text' }],
}
```
### Scheduled Content (Publish Date Range)
```ts
import type { Access } from 'payload'
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 } }],
},
],
}
}
```
**Source**: Synthesized (query constraint + date patterns)
## Subscription-Based Access
### Active Subscription Required
```ts
import type { Access } from 'payload'
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
}
}
// Usage
export const PremiumContent: CollectionConfig = {
slug: 'premium-content',
access: {
read: activeSubscriptionAccess,
},
fields: [{ name: 'title', type: 'text' }],
}
```
### Subscription Tier-Based Access
```ts
import type { Access } from 'payload'
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'),
},
fields: [{ name: 'feature', type: 'text' }],
}
```
**Source**: Synthesized (async + cross-collection pattern)
## Factory Functions
Reusable functions that generate access control configurations.
### createRoleBasedAccess
Generate access control for specific roles.
```ts
import type { Access } from 'payload'
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'])
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: adminOrEditor,
update: adminOrEditor,
delete: moderatorAccess,
},
fields: [{ name: 'title', type: 'text' }],
}
```
**Source**: `test/access-control/config.ts`
### createOrgScopedAccess
Generate organization-scoped access with optional admin bypass.
```ts
import type { Access } from 'payload'
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
export const Projects: CollectionConfig = {
slug: 'projects',
access: {
read: orgScoped,
update: orgScoped,
delete: strictOrgScoped,
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'organizationId', type: 'text', required: true },
],
}
```
**Source**: `test/access-control/config.ts`
### createTeamBasedAccess
Generate team-scoped access with configurable field name.
```ts
import type { Access } from 'payload'
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 || [] },
}
}
}
// Usage with custom field name
const projectTeamAccess = createTeamBasedAccess('projectTeam')
export const Tasks: CollectionConfig = {
slug: 'tasks',
access: {
read: projectTeamAccess,
update: projectTeamAccess,
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'projectTeam', type: 'text', required: true },
],
}
```
**Source**: Synthesized (org pattern variation)
### createTimeLimitedAccess
Generate access limited to records within specified days.
```ts
import type { Access } from 'payload'
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(),
},
}
}
}
// Usage: Users see 90 days, admins see all
export const ActivityLogs: CollectionConfig = {
slug: 'activity-logs',
access: {
read: createTimeLimitedAccess(90),
},
fields: [{ name: 'action', type: 'text' }],
}
```
**Source**: Synthesized (time + query pattern)
## Configuration Templates
Complete collection configurations for common scenarios.
### Basic Authenticated Collection
```ts
import type { CollectionConfig } from 'payload'
export const BasicCollection: CollectionConfig = {
slug: 'basic-collection',
access: {
create: ({ req: { user } }) => Boolean(user),
read: ({ req: { user } }) => Boolean(user),
update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => Boolean(user),
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'content', type: 'richText' },
],
}
```
**Source**: `docs/access-control/collections.mdx`
### Public + Authenticated Collection
```ts
import type { CollectionConfig } from 'payload'
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' },
],
}
```
**Source**: `templates/website/src/collections/Posts/index.ts`
### Multi-User/Self-Service Collection
```ts
import type { CollectionConfig } from 'payload'
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,
},
},
],
}
```
**Source**: `templates/website/src/collections/Users/index.ts`
## Debugging Tips
### Log Access Check Execution
```ts
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
```ts
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
}
```
### Measure Async Operation Timing
```ts
export const timedAsyncAccess: Access = async ({ req }) => {
const start = Date.now()
const result = await fetch('https://auth-service.example.com/validate', {
headers: { userId: req.user?.id },
})
console.log(`Access check took ${Date.now() - start}ms`)
return result.ok
}
```
### Test Access Without User
```ts
// 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)
```
**Source**: Synthesized (debugging best practices)
## Performance Considerations
### Async Operations Impact
```ts
// ❌ 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
```ts
// ❌ 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
```ts
// ❌ Slow: Complex access on array fields
const arrayField: ArrayField = {
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
const optimizedArrayField: ArrayField = {
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
```ts
// ❌ 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 } }
}
```
**Performance Best Practices:**
1. **Minimize Async Operations**: Use query constraints over async lookups when possible
2. **Cache Expensive Checks**: Store results in `req.context` for reuse
3. **Index Query Fields**: Ensure fields in query constraints are indexed
4. **Avoid Complex Logic in Array Fields**: Simple boolean checks preferred
5. **Use Query Constraints**: Let database filter rather than loading all records
**Source**: Synthesized (operational best practices)
## Enhanced Best Practices
Comprehensive security and implementation guidelines:
1. **Default Deny**: Start with restrictive access, gradually add permissions
2. **Type Guards**: Use TypeScript for user type safety and better IDE support
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. **Rate Limit External Calls**: Protect against DoS on external validation services
13. **Handle Errors Gracefully**: Access functions should return `false` on error, not throw
14. **Use Environment Vars**: Store configuration (IPs, API keys) in env vars
15. **Test Local API**: Remember to set `overrideAccess: false` when testing
16. **Consider Performance**: Measure impact of async operations on login time
17. **Version Control**: Track access control changes in git history
18. **Principle of Least Privilege**: Grant minimum access required for functionality
**Sources**: `docs/access-control/*.mdx`, synthesized best practices
@@ -0,0 +1,697 @@
# Payload CMS Access Control Reference
Complete reference for access control patterns across collections, fields, and globals.
## At a Glance
| Feature | Scope | Returns | Use Case |
| --------------------- | --------------------------------------------------------- | ---------------------- | ---------------------------------- |
| **Collection Access** | create, read, update, delete, admin, unlock, readVersions | boolean \| Where query | Document-level permissions |
| **Field Access** | create, read, update | boolean only | Field-level visibility/editability |
| **Global Access** | read, update, readVersions | boolean \| Where query | Global document permissions |
## Three Layers of Access Control
Payload provides three distinct access control layers:
1. **Collection-Level**: Controls operations on entire documents (create, read, update, delete, admin, unlock, readVersions)
2. **Field-Level**: Controls access to individual fields (create, read, update)
3. **Global-Level**: Controls access to global documents (read, update, readVersions)
## Return Value Types
Access control functions can return:
- **Boolean**: `true` (allow) or `false` (deny)
- **Query Constraint**: `Where` object for row-level security (collection-level only)
Field-level access does NOT support query constraints - only boolean returns.
## Operation Decision Tree
```txt
User makes request
│
├─ Collection access check
│ ├─ Returns false? → Deny entire operation
│ ├─ Returns true? → Continue
│ └─ Returns Where? → Apply query constraint
│
├─ Field access check (if applicable)
│ ├─ Returns false? → Field omitted from result
│ └─ Returns true? → Include field
│
└─ Operation completed
```
## Collection Access Control
### Basic Patterns
```ts
import type { CollectionConfig, 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
},
// Admin panel visibility
admin: ({ req: { user } }) => {
return user?.roles?.includes('admin') || user?.roles?.includes('editor')
},
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'status', type: 'select', options: ['draft', 'published'] },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
}
```
### Role-Based Access Control (RBAC) Pattern
Payload does NOT provide a roles system by default. The following is a commonly accepted pattern for implementing role-based access control in auth collections:
```ts
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
defaultValue: ['user'],
required: true,
// Save roles to JWT for access control without database lookups
saveToJWT: true,
access: {
// Only admins can update roles
update: ({ req: { user } }) => user?.roles?.includes('admin'),
},
},
],
}
```
**Important Notes:**
1. **Not Built-In**: Payload does not provide a roles system out of the box. You must add a `roles` field to your auth collection.
2. **Save to JWT**: Use `saveToJWT: true` to include roles in the JWT token, enabling role checks without database queries.
3. **Default Value**: Set a `defaultValue` to automatically assign new users a default role.
4. **Access Control**: Restrict who can modify roles (typically only admins).
5. **Role Options**: Define your own role hierarchy based on your application needs.
**Using Roles in Access Control:**
```ts
import type { Access } from 'payload'
// Check for specific role
export const adminOnly: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin')
}
// Check for multiple roles
export const adminOrEditor: Access = ({ req: { user } }) => {
return Boolean(user?.roles?.some((role) => ['admin', 'editor'].includes(role)))
}
// Role hierarchy check
export const hasMinimumRole: Access = ({ req: { user } }, minRole: string) => {
const roleHierarchy = ['user', 'editor', 'admin']
const userHighestRole = Math.max(...(user?.roles?.map((r) => roleHierarchy.indexOf(r)) || [-1]))
const requiredRoleIndex = roleHierarchy.indexOf(minRole)
return userHighestRole >= requiredRoleIndex
}
```
### Reusable Access Functions
```ts
import type { Access } from 'payload'
// Anyone (public)
export const anyone: Access = () => true
// Authenticated only
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
// Authenticated or published content
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
if (user) return true
return { _status: { equals: 'published' } }
}
// Admin only
export const admins: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin')
}
// Admin or editor
export const adminsOrEditors: Access = ({ req: { user } }) => {
return Boolean(user?.roles?.some((role) => ['admin', 'editor'].includes(role)))
}
// Self or admin
export const adminsOrSelf: Access = ({ req: { user } }) => {
if (user?.roles?.includes('admin')) return true
return { id: { equals: user?.id } }
}
// Usage
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: authenticated,
read: authenticatedOrPublished,
update: adminsOrEditors,
delete: admins,
},
fields: [{ name: 'title', type: 'text' }],
}
```
### Row-Level Security with Complex Queries
```ts
import type { Access } from 'payload'
// 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,
},
}
}
// Multiple conditions with AND
export const complexAccess: Access = ({ req: { user } }) => {
return {
and: [
{ status: { equals: 'published' } },
{ 'author.isActive': { equals: true } },
{
or: [{ visibility: { equals: 'public' } }, { author: { equals: user?.id } }],
},
],
}
}
// 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,
},
}
}
```
### Header-Based Access (API Keys)
```ts
import type { Access } from 'payload'
export const apiKeyAccess: Access = ({ req }) => {
const apiKey = req.headers.get('x-api-key')
if (!apiKey) return false
// Validate against stored keys
return apiKey === process.env.VALID_API_KEY
}
// Bearer token validation
export const bearerTokenAccess: Access = async ({ req }) => {
const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) return false
const token = auth.slice(7)
const isValid = await validateToken(token)
return isValid
}
```
## Field Access Control
Field access does NOT support query constraints - only boolean returns.
### Basic Field Access
```ts
import type { NumberField, FieldAccess } from 'payload'
const salaryReadAccess: FieldAccess = ({ req: { user }, doc }) => {
// Self can read own salary
if (user?.id === doc?.id) return true
// Admin can read all salaries
return user?.roles?.includes('admin')
}
const salaryUpdateAccess: FieldAccess = ({ req: { user } }) => {
// Only admins can update salary
return user?.roles?.includes('admin')
}
const salaryField: NumberField = {
name: 'salary',
type: 'number',
access: {
read: salaryReadAccess,
update: salaryUpdateAccess,
},
}
```
### Sibling Data Access
```ts
import type { ArrayField, FieldAccess } from 'payload'
const contentReadAccess: FieldAccess = ({ req: { user }, siblingData }) => {
// Authenticated users see all
if (user) return true
// Public sees only if marked public
return siblingData?.isPublic === true
}
const arrayField: ArrayField = {
name: 'sections',
type: 'array',
fields: [
{
name: 'isPublic',
type: 'checkbox',
defaultValue: false,
},
{
name: 'content',
type: 'text',
access: {
read: contentReadAccess,
},
},
],
}
```
### Nested Field Access
```ts
import type { GroupField, FieldAccess } from 'payload'
const internalOnlyAccess: FieldAccess = ({ req: { user } }) => {
return user?.roles?.includes('admin') || user?.roles?.includes('internal')
}
const groupField: GroupField = {
name: 'internalMetadata',
type: 'group',
access: {
read: internalOnlyAccess,
update: internalOnlyAccess,
},
fields: [
{ name: 'internalNotes', type: 'textarea' },
{ name: 'priority', type: 'select', options: ['low', 'medium', 'high'] },
],
}
```
### Hiding Admin Fields
```ts
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
access: {
// Hide from UI, but still saved/queried
read: ({ req: { user } }) => user?.roles?.includes('admin'),
// Only admins can update roles
update: ({ req: { user } }) => user?.roles?.includes('admin'),
},
},
],
}
```
## Global Access Control
```ts
import type { GlobalConfig, Access } from 'payload'
const adminOnly: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin')
}
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
access: {
read: () => true, // Anyone can read settings
update: adminOnly, // Only admins can update
readVersions: adminOnly, // Only admins can see version history
},
fields: [
{ name: 'siteName', type: 'text' },
{ name: 'maintenanceMode', type: 'checkbox' },
],
}
```
## Multi-Tenant Access Control
```ts
import type { Access, CollectionConfig } from 'payload'
// Add tenant field to user type
interface User {
id: string
tenantId: string
roles?: string[]
}
// Tenant-scoped access
const tenantAccess: Access = ({ req: { user } }) => {
// No user = no access
if (!user) return false
// Super admin sees all
if (user.roles?.includes('super-admin')) return true
// Users see only their tenant's data
return {
tenant: {
equals: (user as User).tenantId,
},
}
}
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: tenantAccess,
read: tenantAccess,
update: tenantAccess,
delete: tenantAccess,
},
fields: [
{ name: 'title', type: 'text' },
{
name: 'tenant',
type: 'text',
required: true,
access: {
// Tenant field hidden from non-admins
update: ({ req: { user } }) => user?.roles?.includes('super-admin'),
},
hooks: {
// Auto-set tenant on create
beforeChange: [
({ req, operation, value }) => {
if (operation === 'create' && !value) {
return (req.user as User)?.tenantId
}
return value
},
],
},
},
],
}
```
## Auth Collection Patterns
### Self or Admin Pattern
```ts
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
access: {
// Anyone can read user profiles
read: () => true,
// Users can update themselves, admins can update anyone
update: ({ req: { user }, id }) => {
if (user?.roles?.includes('admin')) return true
return user?.id === id
},
// Only admins can delete
delete: ({ req: { user } }) => user?.roles?.includes('admin'),
},
fields: [
{ name: 'name', type: 'text' },
{ name: 'email', type: 'email' },
],
}
```
### Restrict Self-Updates
```ts
import type { CollectionConfig, FieldAccess } from 'payload'
const preventSelfRoleChange: FieldAccess = ({ req: { user }, id }) => {
// Admins can change anyone's roles
if (user?.roles?.includes('admin')) return true
// Users cannot change their own roles
if (user?.id === id) return false
return false
}
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
access: {
update: preventSelfRoleChange,
},
},
],
}
```
## Cross-Collection Validation
```ts
import type { Access } from 'payload'
// Check if user is a project member before allowing access
export const projectMemberAccess: Access = async ({ req, id }) => {
const { user, payload } = req
if (!user) return false
if (user.roles?.includes('admin')) return true
// Check if document exists and user is member
const project = await payload.findByID({
collection: 'projects',
id: id as string,
depth: 0,
})
return project.members?.includes(user.id)
}
// Prevent deletion if document has dependencies
export const preventDeleteWithDependencies: Access = async ({ req, id }) => {
const { payload } = req
const dependencyCount = await payload.count({
collection: 'related-items',
where: {
parent: { equals: id },
},
})
return dependencyCount === 0
}
```
## Access Control Function Arguments
### Collection Create
```ts
create: ({ req, data }) => boolean | Where
// req: PayloadRequest
// - req.user: Authenticated user (if any)
// - req.payload: Payload instance for queries
// - req.headers: Request headers
// - req.locale: Current locale
// data: The data being created
```
### Collection Read
```ts
read: ({ req, id }) => boolean | Where
// req: PayloadRequest
// id: Document ID being read
// - undefined during Access Operation (login check)
// - string when reading specific document
```
### Collection Update
```ts
update: ({ req, id, data }) => boolean | Where
// req: PayloadRequest
// id: Document ID being updated
// data: New values being applied
```
### Collection Delete
```ts
delete: ({ req, id }) => boolean | Where
// req: PayloadRequest
// id: Document ID being deleted
```
### Field Create
```ts
access: {
create: ({ req, data, siblingData }) => boolean
}
// req: PayloadRequest
// data: Full document data
// siblingData: Adjacent field values at same level
```
### Field Read
```ts
access: {
read: ({ req, id, doc, siblingData }) => boolean
}
// req: PayloadRequest
// id: Document ID
// doc: Full document
// siblingData: Adjacent field values
```
### Field Update
```ts
access: {
update: ({ req, id, data, doc, siblingData }) => boolean
}
// req: PayloadRequest
// id: Document ID
// data: New values
// doc: Current document
// siblingData: Adjacent field values
```
## Important Notes
1. **Local API Default**: Access control is **skipped by default** in Local API (`overrideAccess: true`). When passing a `user` parameter, you almost always want to set `overrideAccess: false` to respect that user's permissions:
```ts
// ❌ WRONG: Passes user but bypasses access control (default behavior)
await payload.find({
collection: 'posts',
user: someUser, // User is ignored for access control!
})
// ✅ CORRECT: Respects the user's permissions
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // Required to enforce access control
})
```
**Why this matters**: If you pass `user` without `overrideAccess: false`, the operation runs with admin privileges regardless of the user's actual permissions. This is a common security mistake.
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.
4. **Access Before Hooks**: Access control executes BEFORE hooks run, so hooks cannot modify access behavior.
5. **Query Constraints**: Only collection-level `read` access supports query constraints. All other operations and field-level access require boolean returns.
## Best Practices
1. **Reusable Functions**: Create named access functions for common patterns
2. **Fail Secure**: Default to `false` for sensitive operations
3. **Cache Checks**: Use `req.context` to cache expensive validation
4. **Type Safety**: Type your user object for better IDE support
5. **Test Thoroughly**: Write tests for complex access control logic
6. **Document Intent**: Add comments explaining access rules
7. **Audit Logs**: Track access control decisions for security review
8. **Performance**: Avoid N+1 queries in access functions
9. **Error Handling**: Access functions should not throw - return `false` instead
10. **Tenant Hooks**: Auto-set tenant fields in `beforeChange` hooks
## Advanced Patterns
For advanced access control patterns including context-aware access, time-based restrictions, subscription-based access, factory functions, configuration templates, debugging tips, and performance optimization, see [ACCESS-CONTROL-ADVANCED.md](ACCESS-CONTROL-ADVANCED.md).
@@ -0,0 +1,326 @@
# Payload CMS Adapters Reference
Complete reference for database, storage, and email adapters.
## Database Adapters
### MongoDB
```ts
import { mongooseAdapter } from '@payloadcms/db-mongodb'
export default buildConfig({
db: mongooseAdapter({
url: process.env.DATABASE_URL,
}),
})
```
### Postgres
```ts
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
```ts
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. Pass `req` to include operations in the same transaction.
```ts
import type { CollectionAfterChangeHook } from 'payload'
const afterChange: CollectionAfterChangeHook = async ({ req, doc }) => {
// This will be part of the same transaction
await req.payload.create({
req, // Pass req to use same transaction
collection: 'audit-log',
data: { action: 'created', docId: doc.id },
})
}
// Manual transaction control
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
}
```
**Note**: MongoDB requires replicaset for transactions. SQLite requires `transactionOptions: {}` to enable.
### Threading req Through Operations
**Critical**: When performing nested operations in hooks, always pass `req` to maintain transaction context. Failing to do so breaks atomicity and can cause partial updates.
```ts
import type { CollectionAfterChangeHook } from 'payload'
// ✅ 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
**When req is Required:**
- All mutating operations in hooks (create, update, delete)
- Operations that must succeed/fail together
- When using MongoDB replica sets or Postgres
- Any operation that relies on `req.context` or `req.user`
**When req is Optional:**
- Read-only lookups independent of current transaction
- Operations with `disableTransaction: true`
- Administrative operations with `overrideAccess: true`
## 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
```ts
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,
},
}),
],
})
```
### Azure Blob Storage
```ts
import { azureStorage } from '@payloadcms/storage-azure'
export default buildConfig({
plugins: [
azureStorage({
collections: {
media: true,
},
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
containerName: process.env.AZURE_STORAGE_CONTAINER_NAME,
}),
],
})
```
### Google Cloud Storage
```ts
import { gcsStorage } from '@payloadcms/storage-gcs'
export default buildConfig({
plugins: [
gcsStorage({
collections: {
media: true,
},
bucket: process.env.GCS_BUCKET,
options: {
projectId: process.env.GCS_PROJECT_ID,
credentials: JSON.parse(process.env.GCS_CREDENTIALS),
},
}),
],
})
```
### Cloudflare R2
```ts
import { r2Storage } from '@payloadcms/storage-r2'
export default buildConfig({
plugins: [
r2Storage({
collections: {
media: true,
},
bucket: process.env.R2_BUCKET,
config: {
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
region: 'auto',
endpoint: process.env.R2_ENDPOINT,
},
}),
],
})
```
### Vercel Blob
```ts
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
export default buildConfig({
plugins: [
vercelBlobStorage({
collections: {
media: true,
},
token: process.env.BLOB_READ_WRITE_TOKEN,
}),
],
})
```
### Uploadthing
```ts
import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
export default buildConfig({
plugins: [
uploadthingStorage({
collections: {
media: true,
},
options: {
token: process.env.UPLOADTHING_TOKEN,
acl: 'public-read',
},
}),
],
})
```
## Email Adapters
### Nodemailer (SMTP)
```ts
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
export default buildConfig({
email: nodemailerAdapter({
defaultFromAddress: '[email protected]',
defaultFromName: 'My App',
transportOptions: {
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
}),
})
```
### Resend
```ts
import { resendAdapter } from '@payloadcms/email-resend'
export default buildConfig({
email: resendAdapter({
defaultFromAddress: '[email protected]',
defaultFromName: 'My App',
apiKey: process.env.RESEND_API_KEY,
}),
})
```
@@ -0,0 +1,386 @@
# Payload CMS Advanced Features
Complete reference for authentication, jobs, custom endpoints, components, plugins, and localization.
## Authentication
### Login
```ts
// REST API
const response = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: '[email protected]',
password: 'password',
}),
})
// Local API
const result = await payload.login({
collection: 'users',
data: {
email: '[email protected]',
password: 'password',
},
})
```
### Forgot Password
```ts
await payload.forgotPassword({
collection: 'users',
data: {
email: '[email protected]',
},
})
```
### Custom Strategy
```ts
import type { CollectionConfig, Strategy } from 'payload'
const customStrategy: Strategy = {
name: 'custom',
authenticate: async ({ payload, headers }) => {
const token = headers.get('authorization')?.split(' ')[1]
if (!token) return { user: null }
const user = await verifyToken(token)
return { user }
},
}
export const Users: CollectionConfig = {
slug: 'users',
auth: {
strategies: [customStrategy],
},
fields: [],
}
```
### API Keys
```ts
import type { CollectionConfig } from 'payload'
export const APIKeys: CollectionConfig = {
slug: 'api-keys',
auth: {
disableLocalStrategy: true,
useAPIKey: true,
},
fields: [],
}
```
## Jobs Queue
Offload long-running or scheduled tasks to background workers.
### Tasks
```ts
import { buildConfig } from 'payload'
import type { TaskConfig } from 'payload'
export default buildConfig({
jobs: {
tasks: [
{
slug: 'sendWelcomeEmail',
inputSchema: [
{ name: 'userEmail', type: 'text', required: true },
{ name: 'userName', type: 'text', required: true },
],
outputSchema: [{ name: 'emailSent', type: 'checkbox', required: true }],
retries: 2, // Retry up to 2 times on failure
handler: async ({ input, req }) => {
await sendEmail({
to: input.userEmail,
subject: `Welcome ${input.userName}`,
})
return { output: { emailSent: true } }
},
} as TaskConfig<'sendWelcomeEmail'>,
],
},
})
```
### Queueing Jobs
```ts
// In a hook or endpoint
await req.payload.jobs.queue({
task: 'sendWelcomeEmail',
input: {
userEmail: '[email protected]',
userName: 'John',
},
waitUntil: new Date('2024-12-31'), // Optional: schedule for future
})
```
### Workflows
Multi-step jobs that run in sequence:
```ts
{
slug: 'onboardUser',
inputSchema: [{ name: 'userId', type: 'text' }],
handler: async ({ job, req }) => {
const results = await job.runInlineTask({
task: async ({ input }) => {
// Step 1: Send welcome email
await sendEmail(input.userId)
return { output: { emailSent: true } }
},
})
await job.runInlineTask({
task: async () => {
// Step 2: Create onboarding tasks
await createTasks()
return { output: { tasksCreated: true } }
},
})
},
}
```
## Custom Endpoints
Add custom REST API routes to collections, globals, or root config. See [ENDPOINTS.md](ENDPOINTS.md) for detailed patterns, authentication, helpers, and real-world examples.
### Root Endpoints
```ts
import { buildConfig } from 'payload'
import type { Endpoint } from 'payload'
const helloEndpoint: Endpoint = {
path: '/hello',
method: 'get',
handler: () => {
return Response.json({ message: 'Hello!' })
},
}
const greetEndpoint: Endpoint = {
path: '/greet/:name',
method: 'get',
handler: (req) => {
return Response.json({
message: `Hello ${req.routeParams.name}!`,
})
},
}
export default buildConfig({
endpoints: [helloEndpoint, greetEndpoint],
collections: [],
secret: process.env.PAYLOAD_SECRET || '',
})
```
### Collection Endpoints
```ts
import type { CollectionConfig, Endpoint } from 'payload'
const featuredEndpoint: Endpoint = {
path: '/featured',
method: 'get',
handler: async (req) => {
const posts = await req.payload.find({
collection: 'posts',
where: { featured: { equals: true } },
})
return Response.json(posts)
},
}
export const Posts: CollectionConfig = {
slug: 'posts',
endpoints: [featuredEndpoint],
fields: [
{ name: 'title', type: 'text' },
{ name: 'featured', type: 'checkbox' },
],
}
```
## Custom Components
### Field Component (Client)
```tsx
'use client'
import { useField } from '@payloadcms/ui'
import type { TextFieldClientComponent } from 'payload'
export const CustomField: TextFieldClientComponent = () => {
const { value, setValue } = useField()
return <input value={value || ''} onChange={(e) => setValue(e.target.value)} />
}
```
### Custom View
```tsx
'use client'
import { DefaultTemplate } from '@payloadcms/next/templates'
export const CustomView = () => {
return (
<DefaultTemplate>
<h1>Custom Dashboard</h1>
{/* Your content */}
</DefaultTemplate>
)
}
```
### Admin Config
```ts
import { buildConfig } from 'payload'
export default buildConfig({
admin: {
components: {
beforeDashboard: ['/components/BeforeDashboard'],
beforeLogin: ['/components/BeforeLogin'],
views: {
custom: {
Component: '/views/Custom',
path: '/custom',
},
},
},
},
collections: [],
secret: process.env.PAYLOAD_SECRET || '',
})
```
## Plugins
### Available Plugins
- **@payloadcms/plugin-seo** - SEO fields with meta title/description, Open Graph, preview generation
- **@payloadcms/plugin-redirects** - Manage URL redirects (301/302) for Next.js apps
- **@payloadcms/plugin-nested-docs** - Hierarchical document structures with breadcrumbs
- **@payloadcms/plugin-form-builder** - Dynamic form builder with submissions and validation
- **@payloadcms/plugin-search** - Full-text search integration (Algolia support)
- **@payloadcms/plugin-stripe** - Stripe payments, subscriptions, webhooks
- **@payloadcms/plugin-ecommerce** - Complete ecommerce solution (products, variants, carts, orders)
- **@payloadcms/plugin-import-export** - Import/export data via CSV
- **@payloadcms/plugin-multi-tenant** - Multi-tenancy with tenant isolation
- **@payloadcms/plugin-sentry** - Sentry error tracking integration
- **@payloadcms/plugin-mcp** - Model Context Protocol for AI integrations
### Using Plugins
```ts
import { buildConfig } from 'payload'
import { seoPlugin } from '@payloadcms/plugin-seo'
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
export default buildConfig({
plugins: [
seoPlugin({
collections: ['posts', 'pages'],
}),
redirectsPlugin({
collections: ['pages'],
}),
],
collections: [],
secret: process.env.PAYLOAD_SECRET || '',
})
```
### Creating Plugins
```ts
import type { Config } from 'payload'
interface PluginOptions {
enabled?: boolean
}
export const myPlugin =
(options: PluginOptions) =>
(config: Config): Config => ({
...config,
collections: [
...(config.collections || []),
{
slug: 'plugin-collection',
fields: [{ name: 'title', type: 'text' }],
},
],
onInit: async (payload) => {
if (config.onInit) await config.onInit(payload)
// Plugin initialization
},
})
```
## Localization
```ts
import { buildConfig } from 'payload'
import type { Field, Payload } from 'payload'
export default buildConfig({
localization: {
locales: ['en', 'es', 'de'],
defaultLocale: 'en',
fallback: true,
},
collections: [],
secret: process.env.PAYLOAD_SECRET || '',
})
// Localized field
const localizedField: TextField = {
name: 'title',
type: 'text',
localized: true,
}
// Query with locale
const posts = await payload.find({
collection: 'posts',
locale: 'es',
})
```
## TypeScript Type References
For complete TypeScript type definitions and signatures, reference these files from the Payload source:
### Core Configuration Types
- **[All Commonly-Used Types](https://github.com/payloadcms/payload/blob/3.x/packages/payload/src/index.ts)** - Check here first for commonly used types and interfaces. All core types are exported from this file.
### Database & Adapters
- **[Database Adapter Types](https://github.com/payloadcms/payload/blob/3.x/packages/payload/src/database/types.ts)** - Base adapter interface
- **[MongoDB Adapter](https://github.com/payloadcms/payload/blob/3.x/packages/db-mongodb/src/index.ts)** - MongoDB-specific options
- **[Postgres Adapter](https://github.com/payloadcms/payload/blob/3.x/packages/db-postgres/src/index.ts)** - Postgres-specific options
### Rich Text & Plugins
- **[Lexical Types](https://github.com/payloadcms/payload/blob/3.x/packages/richtext-lexical/src/exports/server/index.ts)** - Lexical editor configuration
When users need detailed type information, fetch these URLs to provide complete signatures and optional parameters.
@@ -0,0 +1,303 @@
# Payload CMS Collections Reference
Complete reference for collection configurations and patterns.
## Basic Collection
```ts
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
labels: {
singular: 'Post',
plural: 'Posts',
},
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'createdAt'],
group: 'Content', // Organize in admin sidebar
description: 'Blog posts and articles',
listSearchableFields: ['title', 'slug'],
},
fields: [
{
name: 'title',
type: 'text',
required: true,
index: true,
},
{
name: 'slug',
type: 'text',
unique: true,
index: true,
admin: { position: 'sidebar' },
},
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
defaultValue: 'draft',
},
],
defaultSort: '-createdAt',
timestamps: true,
}
```
## Auth Collection
```ts
export const Users: CollectionConfig = {
slug: 'users',
auth: {
tokenExpiration: 7200, // 2 hours
verify: true,
maxLoginAttempts: 5,
lockTime: 600000, // 10 minutes
useAPIKey: true,
},
admin: {
useAsTitle: 'email',
},
fields: [
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
required: true,
defaultValue: ['user'],
saveToJWT: true,
},
{
name: 'name',
type: 'text',
required: true,
},
],
}
```
## Upload Collection
```ts
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,
},
{
name: 'caption',
type: 'text',
localized: true,
},
],
}
```
## Live Preview
Enable real-time content preview during editing.
```ts
import type { CollectionConfig } from 'payload'
const generatePreviewPath = ({
slug,
collection,
req,
}: {
slug: string
collection: string
req: any
}) => {
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL
return `${baseUrl}/api/preview?slug=${slug}&collection=${collection}`
}
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
// Live preview during editing
livePreview: {
url: ({ data, req }) =>
generatePreviewPath({
slug: data?.slug as string,
collection: 'pages',
req,
}),
},
// Static preview button
preview: (data, { req }) =>
generatePreviewPath({
slug: data?.slug as string,
collection: 'pages',
req,
}),
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'slug', type: 'text' },
],
}
```
## Versioning & Drafts
Payload maintains version history and supports draft/publish workflows.
```ts
import type { CollectionConfig } from 'payload'
// Basic versioning (audit log only)
export const Users: CollectionConfig = {
slug: 'users',
versions: true, // or { maxPerDoc: 100 }
fields: [{ name: 'name', type: 'text' }],
}
// Drafts enabled (draft/publish workflow)
export const Posts: CollectionConfig = {
slug: 'posts',
versions: {
drafts: true, // Enables _status field
maxPerDoc: 50,
},
fields: [{ name: 'title', type: 'text' }],
}
// Full configuration with autosave and scheduled publish
export const Pages: CollectionConfig = {
slug: 'pages',
versions: {
drafts: {
autosave: true, // Auto-save while editing
schedulePublish: true, // Schedule future publish/unpublish
validate: false, // Don't validate drafts (default)
},
maxPerDoc: 100, // Keep last 100 versions (0 = unlimited)
},
fields: [{ name: 'title', type: 'text' }],
}
```
### Draft API Usage
```ts
// Create draft
await payload.create({
collection: 'posts',
data: { title: 'Draft Post' },
draft: true, // Saves as draft, skips required field validation
})
// Update as draft
await payload.update({
collection: 'posts',
id: '123',
data: { title: 'Updated Draft' },
draft: true,
})
// Read with drafts (returns newest draft if available)
const post = await payload.findByID({
collection: 'posts',
id: '123',
draft: true, // Returns draft version if exists
})
// Query only published (REST API)
// GET /api/posts (returns only _status: 'published')
// Access control for drafts
export const Posts: CollectionConfig = {
slug: 'posts',
versions: { drafts: true },
access: {
read: ({ req: { user } }) => {
// Public can only see published
if (!user) return { _status: { equals: 'published' } }
// Authenticated can see all
return true
},
},
fields: [{ name: 'title', type: 'text' }],
}
```
### Document Status
The `_status` field is auto-injected when drafts are enabled:
- `draft` - Never published
- `published` - Published with no newer drafts
- `changed` - Published but has newer unpublished drafts
## Globals
Globals are single-instance documents (not collections).
```ts
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',
},
],
},
],
}
```
@@ -0,0 +1,634 @@
# Payload Custom API Endpoints Reference
Custom REST API endpoints extend Payload's auto-generated CRUD operations with custom logic, authentication flows, webhooks, and integrations.
## Quick Reference
### Endpoint Configuration
| Property | Type | Description |
| --------- | ------------------------------------------------- | --------------------------------------------------------------- |
| `path` | `string` | Route path after collection/global slug (e.g., `/:id/tracking`) |
| `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete'` | HTTP method (lowercase) |
| `handler` | `(req: PayloadRequest) => Promise<Response>` | Async function returning Web API Response |
| `custom` | `Record<string, any>` | Extension point for plugins/metadata |
### Request Context
| Property | Type | Description |
| ----------------- | ----------------------- | ------------------------------------------------------ |
| `req.user` | `User \| null` | Authenticated user (null if not authenticated) |
| `req.payload` | `Payload` | Payload instance for operations (find, create...) |
| `req.routeParams` | `Record<string, any>` | Path parameters (e.g., `:id`) |
| `req.url` | `string` | Full request URL |
| `req.method` | `string` | HTTP method |
| `req.headers` | `Headers` | Request headers |
| `req.json()` | `() => Promise<any>` | Parse JSON body |
| `req.text()` | `() => Promise<string>` | Read body as text |
| `req.data` | `any` | Parsed body (after `addDataAndFileToRequest()`) |
| `req.file` | `File` | Uploaded file (after `addDataAndFileToRequest()`) |
| `req.locale` | `string` | Request locale (after `addLocalesToRequestFromData()`) |
| `req.i18n` | `I18n` | i18n instance |
| `req.t` | `TFunction` | Translation function |
## Common Patterns
### Authentication Check
Custom endpoints are **not authenticated by default**. Check `req.user` to enforce authentication.
```ts
import { APIError } from 'payload'
export const authenticatedEndpoint = {
path: '/protected',
method: 'get',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
// User is authenticated
return Response.json({ message: 'Access granted' })
},
}
```
### Using Payload Operations
Use `req.payload` for database operations with access control and hooks.
```ts
export const getRelatedPosts = {
path: '/:id/related',
method: 'get',
handler: async (req) => {
const { id } = req.routeParams
// Find related posts
const posts = await req.payload.find({
collection: 'posts',
where: {
category: {
equals: id,
},
},
limit: 5,
sort: '-createdAt',
})
return Response.json(posts)
},
}
```
### Route Parameters
Access path parameters via `req.routeParams`.
```ts
export const getTrackingEndpoint = {
path: '/:id/tracking',
method: 'get',
handler: async (req) => {
const orderId = req.routeParams.id
const tracking = await getTrackingInfo(orderId)
if (!tracking) {
return Response.json({ error: 'not found' }, { status: 404 })
}
return Response.json(tracking)
},
}
```
### Request Body Handling
**Option 1: Manual JSON parsing**
```ts
export const createEndpoint = {
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)
},
}
```
**Option 2: Using helper (handles JSON + files)**
```ts
import { addDataAndFileToRequest } from 'payload'
export const uploadEndpoint = {
path: '/upload',
method: 'post',
handler: async (req) => {
await addDataAndFileToRequest(req)
// req.data now 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)
},
}
```
### CORS Headers
Use `headersWithCors` helper to apply config CORS settings.
```ts
import { headersWithCors } from 'payload'
export const corsEndpoint = {
path: '/public-data',
method: 'get',
handler: async (req) => {
const data = await fetchPublicData()
return Response.json(data, {
headers: headersWithCors({
headers: new Headers(),
req,
}),
})
},
}
```
### Error Handling
Throw `APIError` with status codes for proper error responses.
```ts
import { APIError } from 'payload'
export const validateEndpoint = {
path: '/validate',
method: 'post',
handler: async (req) => {
const data = await req.json()
if (!data.email) {
throw new APIError('Email is required', 400)
}
// Validation passed
return Response.json({ valid: true })
},
}
```
### Query Parameters
Extract query params from URL.
```ts
export const searchEndpoint = {
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)
},
}
```
## Helper Functions
### addDataAndFileToRequest
Parses request body and attaches to `req.data` and `req.file`.
```ts
import { addDataAndFileToRequest } from 'payload'
export const endpoint = {
path: '/process',
method: 'post',
handler: async (req) => {
await addDataAndFileToRequest(req)
// req.data: parsed JSON or form data
// req.file: uploaded file (if multipart)
console.log(req.data) // { title: 'My Post' }
console.log(req.file) // File object or undefined
},
}
```
**Handles:**
- JSON bodies (`Content-Type: application/json`)
- Form data (`Content-Type: multipart/form-data`)
- File uploads
### addLocalesToRequestFromData
Extracts locale from request data and validates against config.
```ts
import { addLocalesToRequestFromData } from 'payload'
export const endpoint = {
path: '/translate',
method: 'post',
handler: async (req) => {
await addLocalesToRequestFromData(req)
// req.locale: validated locale string
// req.fallbackLocale: fallback locale string
const result = await req.payload.find({
collection: 'posts',
locale: req.locale,
})
return Response.json(result)
},
}
```
### headersWithCors
Applies CORS headers from Payload config.
```ts
import { headersWithCors } from 'payload'
export const endpoint = {
path: '/data',
method: 'get',
handler: async (req) => {
const data = { message: 'Hello' }
return Response.json(data, {
headers: headersWithCors({
headers: new Headers({
'Cache-Control': 'public, max-age=3600',
}),
req,
}),
})
},
}
```
## Real-World Examples
### Multi-Tenant Login Endpoint
From `examples/multi-tenant`:
```ts
import { APIError, generatePayloadCookie, headersWithCors } from 'payload'
export const externalUsersLogin = {
path: '/login-external',
method: 'post',
handler: async (req) => {
const { email, password, tenant } = await req.json()
if (!email || !password || !tenant) {
throw new APIError('Missing credentials', 400)
}
// Find user with tenant constraint
const userQuery = await req.payload.find({
collection: 'users',
where: {
and: [
{ email: { equals: email } },
{
or: [{ tenants: { equals: tenant } }, { 'tenants.tenant': { equals: tenant } }],
},
],
},
})
if (!userQuery.docs.length) {
throw new APIError('Invalid credentials', 401)
}
// Authenticate user
const result = await req.payload.login({
collection: 'users',
data: { email, password },
})
return Response.json(result, {
headers: headersWithCors({
headers: new Headers({
'Set-Cookie': generatePayloadCookie({
collectionAuthConfig: req.payload.config.collections.find((c) => c.slug === 'users')
.auth,
cookiePrefix: req.payload.config.cookiePrefix,
token: result.token,
}),
}),
req,
}),
})
},
}
```
### Webhook Handler (Stripe)
From `packages/plugin-ecommerce`:
```ts
export const webhookEndpoint = {
path: '/webhooks',
method: 'post',
handler: async (req) => {
const body = await req.text()
const signature = req.headers.get('stripe-signature')
try {
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
// Process event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(req.payload, event.data.object)
break
case 'payment_intent.failed':
await handlePaymentFailure(req.payload, event.data.object)
break
}
return Response.json({ received: true })
} catch (err) {
req.payload.logger.error(`Webhook error: ${err.message}`)
return Response.json({ error: err.message }, { status: 400 })
}
},
}
```
### Data Preview Endpoint
From `packages/plugin-import-export`:
```ts
import { addDataAndFileToRequest } from 'payload'
export const previewEndpoint = {
path: '/preview',
method: 'post',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
await addDataAndFileToRequest(req)
const { collection, where, limit = 10 } = req.data
// Validate collection exists
const collectionConfig = req.payload.config.collections.find((c) => c.slug === collection)
if (!collectionConfig) {
throw new APIError('Collection not found', 404)
}
// Preview data
const results = await req.payload.find({
collection,
where,
limit,
depth: 0,
})
return Response.json({
docs: results.docs,
totalDocs: results.totalDocs,
fields: collectionConfig.fields,
})
},
}
```
### Reindex Action Endpoint
From `packages/plugin-search`:
```ts
export const reindexEndpoint = (pluginConfig) => ({
path: '/reindex',
method: 'post',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
const { collection } = req.routeParams
// Reindex collection
const result = await reindexCollection(req.payload, collection, pluginConfig)
return Response.json({
message: `Reindexed ${result.count} documents`,
count: result.count,
})
},
})
```
## Endpoint Placement
### Collection Endpoints
Mounted at `/api/{collection-slug}/{path}`.
```ts
import type { CollectionConfig } from 'payload'
export const Orders: CollectionConfig = {
slug: 'orders',
fields: [
/* ... */
],
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}`.
```ts
import type { GlobalConfig } from 'payload'
export const Settings: GlobalConfig = {
slug: 'settings',
fields: [
/* ... */
],
endpoints: [
{
path: '/clear-cache',
method: 'post',
handler: async (req) => {
// Available at: /api/globals/settings/clear-cache
await clearCache()
return Response.json({ message: 'Cache cleared' })
},
},
],
}
```
## Advanced Patterns
### Factory Functions
Create reusable endpoint factories for plugins.
```ts
export const createWebhookEndpoint = (config) => ({
path: '/webhook',
method: 'post',
handler: async (req) => {
const signature = req.headers.get('x-webhook-signature')
if (!verifySignature(signature, config.secret)) {
throw new APIError('Invalid signature', 401)
}
const data = await req.json()
await processWebhook(req.payload, data, config)
return Response.json({ received: true })
},
})
```
### Conditional Endpoints
Add endpoints based on config options.
```ts
export const MyCollection: CollectionConfig = {
slug: 'posts',
fields: [
/* ... */
],
endpoints: [
// Always included
{
path: '/public',
method: 'get',
handler: async (req) => Response.json({ data: [] }),
},
// Conditionally included
...(process.env.ENABLE_ANALYTICS
? [
{
path: '/analytics',
method: 'get',
handler: async (req) => Response.json({ analytics: [] }),
},
]
: []),
],
}
```
### OpenAPI Documentation
Use `custom` property for API documentation metadata.
```ts
export const endpoint = {
path: '/search',
method: 'get',
handler: async (req) => {
// Handler implementation
},
custom: {
openapi: {
summary: 'Search posts',
parameters: [
{
name: 'q',
in: 'query',
required: true,
schema: { type: 'string' },
},
],
responses: {
200: {
description: 'Search results',
content: {
'application/json': {
schema: { type: 'array' },
},
},
},
},
},
},
}
```
## 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`, etc.
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. **Handle CORS** - Use `headersWithCors` for cross-origin requests
8. **Log errors** - Use `req.payload.logger` for debugging
9. **Document with `custom`** - Add OpenAPI metadata for API docs
10. **Factory pattern for reuse** - Create endpoint factories for plugins
## Resources
- REST API Overview: <https://payloadcms.com/docs/rest-api/overview>
- Custom Endpoints: <https://payloadcms.com/docs/rest-api/overview#custom-endpoints>
- Access Control: <https://payloadcms.com/docs/access-control/overview>
- Local API: <https://payloadcms.com/docs/local-api/overview>
@@ -0,0 +1,553 @@
# Payload Field Type Guards Reference
Complete reference with detailed examples and patterns. See [FIELDS.md](FIELDS.md#field-type-guards) for quick reference table of all guards.
## Structural Guards
### fieldHasSubFields
Checks if field contains nested fields (group, array, row, or collapsible).
```ts
import type { Field } from 'payload'
import { fieldHasSubFields } from 'payload'
function traverseFields(fields: Field[]): void {
fields.forEach((field) => {
if (fieldHasSubFields(field)) {
// Safe to access field.fields
traverseFields(field.fields)
}
})
}
```
**Signature:**
```ts
fieldHasSubFields<TField extends ClientField | Field>(
field: TField
): field is TField & (FieldWithSubFieldsClient | FieldWithSubFields)
```
**Common Pattern - Exclude Arrays:**
```ts
if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
// Groups, rows, collapsibles only (not arrays)
}
```
### fieldIsArrayType
Checks if field type is `'array'`.
```ts
import { fieldIsArrayType } from 'payload'
if (fieldIsArrayType(field)) {
// field.type === 'array'
console.log(`Min rows: ${field.minRows}`)
console.log(`Max rows: ${field.maxRows}`)
}
```
**Signature:**
```ts
fieldIsArrayType<TField extends ClientField | Field>(
field: TField
): field is TField & (ArrayFieldClient | ArrayField)
```
### fieldIsBlockType
Checks if field type is `'blocks'`.
```ts
import { fieldIsBlockType } from 'payload'
if (fieldIsBlockType(field)) {
// field.type === 'blocks'
field.blocks.forEach((block) => {
console.log(`Block: ${block.slug}`)
})
}
```
**Signature:**
```ts
fieldIsBlockType<TField extends ClientField | Field>(
field: TField
): field is TField & (BlocksFieldClient | BlocksField)
```
**Common Pattern - Distinguish Containers:**
```ts
if (fieldIsArrayType(field)) {
// Handle array rows
} else if (fieldIsBlockType(field)) {
// Handle block types
}
```
### fieldIsGroupType
Checks if field type is `'group'`.
```ts
import { fieldIsGroupType } from 'payload'
if (fieldIsGroupType(field)) {
// field.type === 'group'
console.log(`Interface: ${field.interfaceName}`)
}
```
**Signature:**
```ts
fieldIsGroupType<TField extends ClientField | Field>(
field: TField
): field is TField & (GroupFieldClient | GroupField)
```
## Capability Guards
### fieldSupportsMany
Checks if field can have multiple values (select, relationship, or upload with `hasMany`).
```ts
import { fieldSupportsMany } from 'payload'
if (fieldSupportsMany(field)) {
// field.type is 'select' | 'relationship' | 'upload'
// Safe to check field.hasMany
if (field.hasMany) {
console.log('Field accepts multiple values')
}
}
```
**Signature:**
```ts
fieldSupportsMany<TField extends ClientField | Field>(
field: TField
): field is TField & (FieldWithManyClient | FieldWithMany)
```
### fieldHasMaxDepth
Checks if field is relationship/upload/join with numeric `maxDepth` property.
```ts
import { fieldHasMaxDepth } from 'payload'
if (fieldHasMaxDepth(field)) {
// field.type is 'upload' | 'relationship' | 'join'
// AND field.maxDepth is number
const remainingDepth = field.maxDepth - currentDepth
}
```
**Signature:**
```ts
fieldHasMaxDepth<TField extends ClientField | Field>(
field: TField
): field is TField & (FieldWithMaxDepthClient | FieldWithMaxDepth)
```
### fieldShouldBeLocalized
Checks if field needs localization handling (accounts for parent localization).
```ts
import { fieldShouldBeLocalized } from 'payload'
function processField(field: Field, parentIsLocalized: boolean) {
if (fieldShouldBeLocalized({ field, parentIsLocalized })) {
// Create locale-specific table or index
}
}
```
**Signature:**
```ts
fieldShouldBeLocalized({
field,
parentIsLocalized,
}: {
field: ClientField | ClientTab | Field | Tab
parentIsLocalized: boolean
}): boolean
```
```ts
// Accounts for parent localization
if (fieldShouldBeLocalized({ field, parentIsLocalized: false })) {
/* ... */
}
```
### fieldIsVirtual
Checks if field is virtual (computed or virtual relationship).
```ts
import { fieldIsVirtual } from 'payload'
if (fieldIsVirtual(field)) {
// field.virtual is truthy
if (typeof field.virtual === 'string') {
// Virtual relationship path
console.log(`Virtual path: ${field.virtual}`)
} else {
// Computed virtual field (uses hooks)
}
}
```
**Signature:**
```ts
fieldIsVirtual(field: Field | Tab): boolean
```
## Data Guards
### fieldAffectsData
**Most commonly used guard.** Checks if field stores data (has name and is not UI-only).
```ts
import { fieldAffectsData } from 'payload'
function generateSchema(fields: Field[]) {
fields.forEach((field) => {
if (fieldAffectsData(field)) {
// Safe to access field.name
schema[field.name] = getFieldType(field)
}
})
}
```
**Signature:**
```ts
fieldAffectsData<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
field: TField
): field is TField & (FieldAffectingDataClient | FieldAffectingData)
```
**Pattern - Data Fields Only:**
```ts
const dataFields = fields.filter(fieldAffectsData)
```
### fieldIsPresentationalOnly
Checks if field is UI-only (type `'ui'`).
```ts
import { fieldIsPresentationalOnly } from 'payload'
if (fieldIsPresentationalOnly(field)) {
// field.type === 'ui'
// Skip in data operations, GraphQL schema, etc.
return
}
```
**Signature:**
```ts
fieldIsPresentationalOnly<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
field: TField
): field is TField & (UIFieldClient | UIField)
```
### fieldIsID
Checks if field name is exactly `'id'`.
```ts
import { fieldIsID } from 'payload'
if (fieldIsID(field)) {
// field.name === 'id'
// Special handling for ID field
}
```
**Signature:**
```ts
fieldIsID<TField extends ClientField | Field>(
field: TField
): field is { name: 'id' } & TField
```
### fieldIsHiddenOrDisabled
Checks if field is hidden or admin-disabled.
```ts
import { fieldIsHiddenOrDisabled } from 'payload'
const visibleFields = fields.filter((field) => !fieldIsHiddenOrDisabled(field))
```
**Signature:**
```ts
fieldIsHiddenOrDisabled<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
field: TField
): field is { admin: { hidden: true } } & TField
```
## Layout Guards
### fieldIsSidebar
Checks if field is positioned in sidebar.
```ts
import { fieldIsSidebar } from 'payload'
const [mainFields, sidebarFields] = fields.reduce(
([main, sidebar], field) => {
if (fieldIsSidebar(field)) {
return [main, [...sidebar, field]]
}
return [[...main, field], sidebar]
},
[[], []],
)
```
**Signature:**
```ts
fieldIsSidebar<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
field: TField
): field is { admin: { position: 'sidebar' } } & TField
```
## Tab & Group Guards
### tabHasName
Checks if tab is named (stores data under tab name).
```ts
import { tabHasName } from 'payload'
tabs.forEach((tab) => {
if (tabHasName(tab)) {
// tab.name exists
dataPath.push(tab.name)
}
// Process tab.fields
})
```
**Signature:**
```ts
tabHasName<TField extends ClientTab | Tab>(
tab: TField
): tab is NamedTab & TField
```
### groupHasName
Checks if group is named (stores data under group name).
```ts
import { groupHasName } from 'payload'
if (groupHasName(group)) {
// group.name exists
return data[group.name]
}
```
**Signature:**
```ts
groupHasName(group: Partial<NamedGroupFieldClient>): group is NamedGroupFieldClient
```
## Option & Value Guards
### optionIsObject
Checks if option is object format `{label, value}` vs string.
```ts
import { optionIsObject } from 'payload'
field.options.forEach((option) => {
if (optionIsObject(option)) {
console.log(`${option.label}: ${option.value}`)
} else {
console.log(option) // string value
}
})
```
**Signature:**
```ts
optionIsObject(option: Option): option is OptionObject
```
### optionsAreObjects
Checks if entire options array contains objects.
```ts
import { optionsAreObjects } from 'payload'
if (optionsAreObjects(field.options)) {
// All options are OptionObject[]
const labels = field.options.map((opt) => opt.label)
}
```
**Signature:**
```ts
optionsAreObjects(options: Option[]): options is OptionObject[]
```
### optionIsValue
Checks if option is string value (not object).
```ts
import { optionIsValue } from 'payload'
if (optionIsValue(option)) {
// option is string
const value = option
}
```
**Signature:**
```ts
optionIsValue(option: Option): option is string
```
### valueIsValueWithRelation
Checks if relationship value is polymorphic format `{relationTo, value}`.
```ts
import { valueIsValueWithRelation } from 'payload'
if (valueIsValueWithRelation(fieldValue)) {
// fieldValue.relationTo exists
// fieldValue.value exists
console.log(`Related to ${fieldValue.relationTo}: ${fieldValue.value}`)
}
```
**Signature:**
```ts
valueIsValueWithRelation(value: unknown): value is ValueWithRelation
```
## Common Patterns
### Recursive Field Traversal
```ts
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
```ts
import { fieldAffectsData, fieldIsPresentationalOnly, fieldIsHiddenOrDisabled } from 'payload'
const dataFields = fields.filter(
(field) =>
fieldAffectsData(field) && !fieldIsPresentationalOnly(field) && !fieldIsHiddenOrDisabled(field),
)
```
### Container Type Switching
```ts
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
```ts
import { fieldSupportsMany, fieldHasMaxDepth } from 'payload'
// Without guard - TypeScript error
// if (field.hasMany) { /* ... */ }
// 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
}
```
## Type Preservation
All guards preserve the original type constraint:
```ts
import type { ClientField, Field } from 'payload'
import { fieldHasSubFields } from 'payload'
function processServerField(field: Field) {
if (fieldHasSubFields(field)) {
// field is Field & FieldWithSubFields (not ClientField)
}
}
function processClientField(field: ClientField) {
if (fieldHasSubFields(field)) {
// field is ClientField & FieldWithSubFieldsClient
}
}
```
+744
View File
@@ -0,0 +1,744 @@
# Payload CMS Field Types Reference
Complete reference for all Payload field types with examples.
## Text Field
```ts
import type { TextField } from 'payload'
const textField: TextField = {
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,
},
}
```
### Slug Field Helper
Built-in helper for auto-generating slugs:
```ts
import { slugField } from 'payload'
import type { CollectionConfig } from 'payload'
export const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{ name: 'title', type: 'text', required: true },
slugField({
name: 'slug', // defaults to 'slug'
useAsSlug: 'title', // defaults to 'title'
checkboxName: 'generateSlug', // defaults to 'generateSlug'
localized: true,
required: true,
overrides: (defaultField) => {
// Customize the generated fields if needed
return defaultField
},
}),
],
}
```
## Rich Text (Lexical)
```ts
import type { RichTextField } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { HeadingFeature, LinkFeature } from '@payloadcms/richtext-lexical'
const richTextField: RichTextField = {
name: 'content',
type: 'richText',
required: true,
localized: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
HeadingFeature({
enabledHeadingSizes: ['h1', 'h2', 'h3'],
}),
LinkFeature({
enabledCollections: ['posts', 'pages'],
}),
],
}),
}
```
### Advanced Lexical Configuration
```ts
import {
BoldFeature,
EXPERIMENTAL_TableFeature,
FixedToolbarFeature,
HeadingFeature,
IndentFeature,
InlineToolbarFeature,
ItalicFeature,
LinkFeature,
OrderedListFeature,
UnderlineFeature,
UnorderedListFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
// Global editor config with full features
export default buildConfig({
editor: lexicalEditor({
features: () => {
return [
UnderlineFeature(),
BoldFeature(),
ItalicFeature(),
OrderedListFeature(),
UnorderedListFeature(),
LinkFeature({
enabledCollections: ['pages'],
fields: ({ defaultFields }) => {
const defaultFieldsWithoutUrl = defaultFields.filter((field) => {
if ('name' in field && field.name === 'url') return false
return true
})
return [
...defaultFieldsWithoutUrl,
{
name: 'url',
type: 'text',
admin: {
condition: ({ linkType }) => linkType !== 'internal',
},
label: ({ t }) => t('fields:enterURL'),
required: true,
},
]
},
}),
IndentFeature(),
EXPERIMENTAL_TableFeature(),
]
},
}),
})
// Field-specific editor with custom toolbar
const richTextWithToolbars: RichTextField = {
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: false,
}
```
## Relationship
```ts
import type { RelationshipField } from 'payload'
// Single relationship
const singleRelationship: RelationshipField = {
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
maxDepth: 2,
}
// Multiple relationships (hasMany)
const multipleRelationship: RelationshipField = {
name: 'categories',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
filterOptions: {
active: { equals: true },
},
}
// Polymorphic relationship
const polymorphicRelationship: PolymorphicRelationshipField = {
name: 'relatedContent',
type: 'relationship',
relationTo: ['posts', 'pages'],
hasMany: true,
}
```
## Array
```ts
import type { ArrayField } from 'payload'
const arrayField: ArrayField = {
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
```ts
import type { BlocksField, 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',
},
],
}
const blocksField: BlocksField = {
name: 'layout',
type: 'blocks',
blocks: [HeroBlock, ContentBlock],
}
```
## Select
```ts
import type { SelectField } from 'payload'
const selectField: SelectField = {
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
required: true,
}
// Multiple select
const multiSelectField: SelectField = {
name: 'tags',
type: 'select',
hasMany: true,
options: ['tech', 'news', 'sports'],
}
```
## Upload
```ts
import type { UploadField } from 'payload'
const uploadField: UploadField = {
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
required: true,
filterOptions: {
mimeType: { contains: 'image' },
},
}
```
## Point (Geolocation)
Point fields store geographic coordinates with automatic 2dsphere indexing for geospatial queries.
```ts
import type { PointField } from 'payload'
const locationField: PointField = {
name: 'location',
type: 'point',
label: 'Location',
required: true,
}
// Returns [longitude, latitude]
// Example: [-122.4194, 37.7749] for San Francisco
```
### Geospatial Queries
```ts
// Query by distance (sorted by nearest first)
const nearbyLocations = await payload.find({
collection: 'stores',
where: {
location: {
near: [10, 20], // [longitude, latitude]
maxDistance: 5000, // in meters
minDistance: 1000,
},
},
})
// Query within polygon area
const polygon: Point[] = [
[9.0, 19.0], // bottom-left
[9.0, 21.0], // top-left
[11.0, 21.0], // top-right
[11.0, 19.0], // bottom-right
[9.0, 19.0], // closing point
]
const withinArea = await payload.find({
collection: 'stores',
where: {
location: {
within: {
type: 'Polygon',
coordinates: [polygon],
},
},
},
})
// Query intersecting area
const intersecting = await payload.find({
collection: 'stores',
where: {
location: {
intersects: {
type: 'Polygon',
coordinates: [polygon],
},
},
},
})
```
**Note**: Point fields are not supported in SQLite.
## Join Fields
Join fields create reverse relationships, allowing you to access related documents from the "other side" of a relationship.
```ts
import type { JoinField } from 'payload'
// From Users collection - show user's orders
const ordersJoinField: JoinField = {
name: 'orders',
type: 'join',
collection: 'orders',
on: 'customer', // The field in 'orders' that references this user
admin: {
allowCreate: false,
defaultColumns: ['id', 'createdAt', 'total', 'currency', 'items'],
},
}
// From Users collection - show user's cart
const cartJoinField: JoinField = {
name: 'cart',
type: 'join',
collection: 'carts',
on: 'customer',
admin: {
allowCreate: false,
defaultColumns: ['id', 'createdAt', 'total', 'currency'],
},
}
```
## Virtual Fields
```ts
import type { TextField } from 'payload'
// Computed from siblings
const computedVirtualField: TextField = {
name: 'fullName',
type: 'text',
virtual: true,
hooks: {
afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`],
},
}
// From relationship path
const pathVirtualField: TextField = {
name: 'authorName',
type: 'text',
virtual: 'author.name',
}
```
## Conditional Fields
```ts
import type { UploadField, CheckboxField } from 'payload'
// Simple boolean condition
const enableFeatureField: CheckboxField = {
name: 'enableFeature',
type: 'checkbox',
}
const conditionalField: TextField = {
name: 'featureText',
type: 'text',
admin: {
condition: (data) => data.enableFeature === true,
},
}
// Sibling data condition (from hero field pattern)
const typeField: SelectField = {
name: 'type',
type: 'select',
options: ['none', 'highImpact', 'mediumImpact', 'lowImpact'],
defaultValue: 'lowImpact',
}
const mediaField: UploadField = {
name: 'media',
type: 'upload',
relationTo: 'media',
admin: {
condition: (_, { type } = {}) => ['highImpact', 'mediumImpact'].includes(type),
},
required: true,
}
```
## Radio
Radio fields present options as radio buttons for single selection.
```ts
import type { RadioField } from 'payload'
const radioField: RadioField = {
name: 'priority',
type: 'radio',
options: [
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
],
defaultValue: 'medium',
admin: {
layout: 'horizontal', // or 'vertical'
},
}
```
## Row (Layout)
Row fields arrange fields horizontally in the admin panel (presentational only).
```ts
import type { RowField } from 'payload'
const rowField: RowField = {
type: 'row',
fields: [
{
name: 'firstName',
type: 'text',
admin: { width: '50%' },
},
{
name: 'lastName',
type: 'text',
admin: { width: '50%' },
},
],
}
```
## Collapsible (Layout)
Collapsible fields group fields in an expandable/collapsible section.
```ts
import type { CollapsibleField } from 'payload'
const collapsibleField: CollapsibleField = {
label: ({ data }) => data?.title || 'Advanced Options',
type: 'collapsible',
admin: {
initCollapsed: true,
},
fields: [
{ name: 'customCSS', type: 'textarea' },
{ name: 'customJS', type: 'code' },
],
}
```
## UI (Custom Components)
UI fields allow fully custom React components in the admin (no data stored).
```ts
import type { UIField } from 'payload'
const uiField: UIField = {
name: 'customMessage',
type: 'ui',
admin: {
components: {
Field: '/path/to/CustomFieldComponent',
Cell: '/path/to/CustomCellComponent', // For list view
},
},
}
```
## Tabs & Groups
```ts
import type { TabsField, GroupField } from 'payload'
// Tabs
const tabsField: TabsField = {
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)
const groupField: GroupField = {
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
],
}
```
## Reusable Field Factories
Create composable field patterns that can be customized with overrides.
```ts
import type { Field, GroupField } from 'payload'
// Utility for deep merging
const deepMerge = <T>(target: T, source: Partial<T>): T => {
// Implementation would deeply merge objects
return { ...target, ...source }
}
// Reusable link field factory
type LinkType = (options?: {
appearances?: ('default' | 'outline')[] | false
disableLabel?: boolean
overrides?: Record<string, unknown>
}) => GroupField
export const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
const linkField: GroupField = {
name: 'link',
type: 'group',
admin: {
hideGutter: true,
},
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'radio',
options: [
{ label: 'Internal link', value: 'reference' },
{ label: 'Custom URL', value: 'custom' },
],
defaultValue: 'reference',
admin: {
layout: 'horizontal',
width: '50%',
},
},
{
name: 'newTab',
type: 'checkbox',
label: 'Open in new tab',
admin: {
width: '50%',
style: {
alignSelf: 'flex-end',
},
},
},
],
},
{
name: 'reference',
type: 'relationship',
relationTo: ['pages'],
required: true,
maxDepth: 1,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
},
{
name: 'url',
type: 'text',
label: 'Custom URL',
required: true,
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
},
],
}
if (!disableLabel) {
linkField.fields.push({
name: 'label',
type: 'text',
required: true,
})
}
if (appearances !== false) {
linkField.fields.push({
name: 'appearance',
type: 'select',
defaultValue: 'default',
options: [
{ label: 'Default', value: 'default' },
{ label: 'Outline', value: 'outline' },
],
})
}
return deepMerge(linkField, overrides) as GroupField
}
// Usage
const navItem = link({ appearances: false })
const ctaButton = link({
overrides: {
name: 'cta',
admin: {
description: 'Call to action button',
},
},
})
```
## Field Type Guards
Type guards for runtime field type checking and safe type narrowing.
| Type Guard | Checks For | Use When |
| --------------------------- | ----------------------------------------------------------- | ---------------------------------------- |
| `fieldAffectsData` | Field stores data (has name, not UI-only) | Need to access field data or name |
| `fieldHasSubFields` | Field contains nested fields (group/array/row/collapsible) | Need to 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 (select/relationship/upload) | Check for `hasMany` support |
| `fieldHasMaxDepth` | Field supports population depth control | Control relationship/upload/join depth |
| `fieldIsPresentationalOnly` | Field is UI-only (no data storage) | 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 handling | Proper locale table checks |
| `fieldIsVirtual` | Field is virtual (computed/no DB column) | 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}` format | 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 |
```ts
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload'
function processField(field: Field) {
if (fieldAffectsData(field)) {
// Safe to access field.name
console.log(field.name)
}
if (fieldHasSubFields(field)) {
// Safe to access field.fields
field.fields.forEach(processField)
}
}
```
See [FIELD-TYPE-GUARDS.md](FIELD-TYPE-GUARDS.md) for detailed usage patterns.
+186
View File
@@ -0,0 +1,186 @@
# Payload CMS Hooks Reference
Complete reference for collection hooks, field hooks, and hook context patterns.
## Collection Hooks
```ts
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
// Before validation
beforeValidate: [
async ({ data, operation }) => {
if (operation === 'create') {
data.slug = slugify(data.title)
}
return data
},
],
// Before save
beforeChange: [
async ({ data, req, operation, originalDoc }) => {
if (operation === 'update' && data.status === 'published') {
data.publishedAt = new Date()
}
return data
},
],
// After save
afterChange: [
async ({ doc, req, operation, previousDoc }) => {
if (operation === 'create') {
await sendNotification(doc)
}
return doc
},
],
// After read
afterRead: [
async ({ doc, req }) => {
doc.viewCount = await getViewCount(doc.id)
return doc
},
],
// Before delete
beforeDelete: [
async ({ req, id }) => {
await cleanupRelatedData(id)
},
],
},
}
```
## Field Hooks
```ts
import type { EmailField, 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
}
const emailField: EmailField = {
name: 'email',
type: 'email',
hooks: {
beforeValidate: [beforeValidateHook],
afterRead: [afterReadHook],
},
}
```
## Hook Context
Share data between hooks or control hook behavior using request context:
```ts
import type { CollectionConfig } from 'payload'
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)
},
],
},
fields: [{ name: 'title', type: 'text' }],
}
```
## Next.js Revalidation with Context Control
```ts
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath } from 'next/cache'
import type { Page } from '../payload-types'
export const revalidatePage: CollectionAfterChangeHook<Page> = ({
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}`
payload.logger.info(`Revalidating old page at path: ${oldPath}`)
revalidatePath(oldPath)
}
}
return doc
}
export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {
if (!context.disableRevalidate) {
const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}`
revalidatePath(path)
}
return doc
}
```
## Date Field Auto-Set
Automatically set date when document is published:
```ts
import type { DateField } from 'payload'
const publishedOnField: DateField = {
name: 'publishedOn',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
position: 'sidebar',
},
hooks: {
beforeChange: [
({ siblingData, value }) => {
if (siblingData._status === 'published' && !value) {
return new Date()
}
return value
},
],
},
}
```
## Hook Patterns 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 (see [ADAPTERS.md#threading-req-through-operations](ADAPTERS.md#threading-req-through-operations))
File diff suppressed because it is too large Load Diff
+274
View File
@@ -0,0 +1,274 @@
# Payload CMS Querying Reference
Complete reference for querying data across Local API, REST, and GraphQL.
## Query Operators
```ts
import type { Where } from 'payload'
// Equals
const equalsQuery: Where = { color: { equals: 'blue' } }
// Not equals
const notEqualsQuery: Where = { status: { not_equals: 'draft' } }
// Greater/less than
const greaterThanQuery: Where = { price: { greater_than: 100 } }
const lessThanEqualQuery: Where = { age: { less_than_equal: 65 } }
// Contains (case-insensitive)
const containsQuery: Where = { title: { contains: 'payload' } }
// Like (all words present)
const likeQuery: Where = { description: { like: 'cms headless' } }
// In/not in
const inQuery: Where = { category: { in: ['tech', 'news'] } }
// Exists
const existsQuery: Where = { image: { exists: true } }
// Near (point fields)
const nearQuery: Where = { location: { near: '-122.4194,37.7749,10000' } }
```
## AND/OR Logic
```ts
import type { Where } from 'payload'
const complexQuery: Where = {
or: [
{ color: { equals: 'mint' } },
{
and: [{ color: { equals: 'white' } }, { featured: { equals: false } }],
},
],
}
```
## Nested Properties
```ts
import type { Where } from 'payload'
const nestedQuery: Where = {
'author.role': { equals: 'editor' },
'meta.featured': { exists: true },
}
```
## Local API
```ts
// Find documents
const posts = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
'author.name': { contains: 'john' },
},
depth: 2,
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' },
},
})
```
### Threading req Parameter
When performing operations in hooks or nested operations, pass the `req` parameter to maintain transaction context:
```ts
// ✅ CORRECT: Pass req for transaction safety
const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { action: 'created', docId: doc.id },
req, // Maintains transaction atomicity
})
}
// ❌ WRONG: Missing req breaks transaction
const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { action: 'created', docId: doc.id },
// Missing req - runs in separate transaction
})
}
```
This is critical for MongoDB replica sets and Postgres. See [ADAPTERS.md#threading-req-through-operations](ADAPTERS.md#threading-req-through-operations) for details.
### Access Control in Local API
**Important**: Local API bypasses access control by default (`overrideAccess: true`). When passing a `user` parameter, you must explicitly set `overrideAccess: false` to respect that user's permissions.
```ts
// ❌ WRONG: User is passed but access control is bypassed
const posts = await payload.find({
collection: 'posts',
user: currentUser,
// Missing: overrideAccess: false
// Result: Operation runs with ADMIN privileges, ignoring user's permissions
})
// ✅ CORRECT: Respects user's access control permissions
const posts = await payload.find({
collection: 'posts',
user: currentUser,
overrideAccess: false, // Required to enforce access control
// Result: User only sees posts they have permission to read
})
// Administrative operation (intentionally bypass access control)
const allPosts = await payload.find({
collection: 'posts',
// No user parameter
// overrideAccess defaults to true
// Result: Returns all posts regardless of access control
})
```
**When to use `overrideAccess: false`:**
- Performing operations on behalf of a user
- Testing access control logic
- API routes that should respect user permissions
- Any operation where `user` parameter is provided
**When `overrideAccess: true` is appropriate:**
- Administrative operations (migrations, seeds, cron jobs)
- Internal system operations
- Operations explicitly intended to bypass access control
See [ACCESS-CONTROL.md#important-notes](ACCESS-CONTROL.md#important-notes) for more details.
## REST API
```ts
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
```txt
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
}
}
mutation {
updatePost(id: "123", data: { status: published }) {
id
status
}
}
mutation {
deletePost(id: "123") {
id
}
}
```
## 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`
+50
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
/dist
# 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
/dev/media
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
}
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {
"type": "es6"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
+24
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}"
}
]
}
+41
View File
@@ -0,0 +1,41 @@
{
"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",
"js/ts.tsdk.path": "node_modules/typescript/lib",
"js/ts.tsdk.promptToUseWorkspaceVersion": true,
"[javascript][typescript][typescriptreact]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
}
+4
View File
@@ -0,0 +1,4 @@
# Claude Code
This project uses the Payload CMS skill at `.claude/skills/payload/`.
Start with `.claude/skills/payload/SKILL.md` for a quick reference, then see `.claude/skills/payload/reference/` for detailed docs.
+218
View File
@@ -0,0 +1,218 @@
# Payload Plugin Template
A template repo to create a [Payload CMS](https://payloadcms.com) plugin.
Payload is built with a robust infrastructure intended to support Plugins with ease. This provides a simple, modular, and reusable way for developers to extend the core capabilities of Payload.
To build your own Payload plugin, all you need is:
- An understanding of the basic Payload concepts
- And some JavaScript/Typescript experience
## Background
Here is a short recap on how to integrate plugins with Payload, to learn more visit the [plugin overview page](https://payloadcms.com/docs/plugins/overview).
### How to install a plugin
To install any plugin, simply add it to your payload.config() in the Plugin array.
```ts
import myPlugin from 'my-plugin'
export const config = buildConfig({
plugins: [
// You can pass options to the plugin
myPlugin({
enabled: true,
}),
],
})
```
### Initialization
The initialization process goes in the following order:
1. Incoming config is validated
2. **Plugins execute**
3. Default options are integrated
4. Sanitization cleans and validates data
5. Final config gets initialized
## Building the Plugin
When you build a plugin, you are purely building a feature for your project and then abstracting it outside of the project.
### Template Files
In the Payload [plugin template](https://github.com/payloadcms/payload/tree/3.x/templates/plugin), you will see a common file structure that is used across all plugins:
1. root folder
2. /src folder
3. /dev folder
#### Root
In the root folder, you will see various files that relate to the configuration of the plugin. We set up our environment in a similar manner in Payload core and across other projects, so hopefully these will look familiar:
- **README**.md\* - This contains instructions on how to use the template. When you are ready, update this to contain instructions on how to use your Plugin.
- **package**.json\* - Contains necessary scripts and dependencies. Overwrite the metadata in this file to describe your Plugin.
- .**eslint**.config.js - Eslint configuration for reporting on problematic patterns.
- .**gitignore** - List specific untracked files to omit from Git.
- .**prettierrc**.json - Configuration for Prettier code formatting.
- **tsconfig**.json - Configures the compiler options for TypeScript
- .**swcrc** - Configuration for SWC, a fast compiler that transpiles and bundles TypeScript.
- **vitest**.config.js - Config file for Vitest, defining how tests are run and how modules are resolved
**IMPORTANT\***: You will need to modify these files.
#### Dev
In the dev folder, you’ll find a basic payload project, created with `npx create-payload-app` and the blank template.
**IMPORTANT**: Make a copy of the `.env.example` file and rename it to `.env`. Update the `DATABASE_URL` to match the database you are using and your plugin name. Update `PAYLOAD_SECRET` to a unique string.
**You will not be able to run `pnpm/yarn dev` until you have created this `.env` file.**
`myPlugin` has already been added to the `payload.config()` file in this project.
```ts
plugins: [
myPlugin({
collections: {
posts: true,
},
}),
]
```
Later when you rename the plugin or add additional options, **make sure to update it here**.
You may wish to add collections or expand the test project depending on the purpose of your plugin. Just make sure to keep this dev environment as simplified as possible - users should be able to install your plugin without additional configuration required.
When you’re ready to start development, initiate the project with `pnpm/npm/yarn dev` and pull up [http://localhost:3000](http://localhost:3000) in your browser.
#### Src
Now that we have our environment setup and we have a dev project ready to - it’s time to build the plugin!
**index.ts**
The essence of a Payload plugin is simply to extend the payload config - and that is exactly what we are doing in this file.
```ts
export const myPlugin =
(pluginOptions: MyPluginConfig) =>
(config: Config): Config => {
// do cool stuff with the config here
return config
}
```
First, we receive the existing payload config along with any plugin options.
From here, you can extend the config as you wish.
Finally, you return the config and that is it!
##### Spread Syntax
Spread syntax (or the spread operator) is a feature in JavaScript that uses the dot notation **(...)** to spread elements from arrays, strings, or objects into various contexts.
We are going to use spread syntax to allow us to add data to existing arrays without losing the existing data. It is crucial to spread the existing data correctly – else this can cause adverse behavior and conflicts with Payload config and other plugins.
Let’s say you want to build a plugin that adds a new collection:
```ts
config.collections = [
...(config.collections || []),
// Add additional collections here
]
```
First we spread the `config.collections` to ensure that we don’t lose the existing collections, then you can add any additional collections just as you would in a regular payload config.
This same logic is applied to other properties like admin, hooks, globals:
```ts
config.globals = [
...(config.globals || []),
// Add additional globals here
]
config.hooks = {
...(incomingConfig.hooks || {}),
// Add additional hooks here
}
```
Some properties will be slightly different to extend, for instance the onInit property:
```ts
import { onInitExtension } from './onInitExtension' // example file
config.onInit = async (payload) => {
if (incomingConfig.onInit) await incomingConfig.onInit(payload)
// Add additional onInit code by defining an onInitExtension function
onInitExtension(pluginOptions, payload)
}
```
If you wish to add to the onInit, you must include the **async/await**. We don’t use spread syntax in this case, instead you must await the existing `onInit` before running additional functionality.
In the template, we have stubbed out some addition `onInit` actions that seeds in a document to the `plugin-collection`, you can use this as a base point to add more actions - and if not needed, feel free to delete it.
##### Types.ts
If your plugin has options, you should define and provide types for these options.
```ts
export type MyPluginConfig = {
/**
* List of collections to add a custom field
*/
collections?: Partial<Record<CollectionSlug, true>>
/**
* Disable the plugin
*/
disabled?: boolean
}
```
If possible, include JSDoc comments to describe the options and their types. This allows a developer to see details about the options in their editor.
##### Testing
Having a test suite for your plugin is essential to ensure quality and stability. **Vitest** is a fast, modern testing framework that works seamlessly with Vite and supports TypeScript out of the box.
Vitest organizes tests into test suites and cases, similar to other testing frameworks. We recommend creating individual tests based on the expected behavior of your plugin from start to finish.
Writing tests with Vitest is very straightforward, and you can learn more about how it works in the [Vitest documentation.](https://vitest.dev/)
For this template, we stubbed out `int.spec.ts` in the `dev` folder where you can write your tests.
```ts
describe('Plugin tests', () => {
// Create tests to ensure expected behavior from the plugin
it('some condition that must be met', () => {
// Write your test logic here
expect(...)
})
})
```
## Best practices
With this tutorial and the plugin template, you should have everything you need to start building your own plugin.
In addition to the setup, here are other best practices aim we follow:
- **Providing an enable / disable option:** For a better user experience, provide a way to disable the plugin without uninstalling it. This is especially important if your plugin adds additional webpack aliases, this will allow you to still let the webpack run to prevent errors.
- **Include tests in your GitHub CI workflow**: If you’ve configured tests for your package, integrate them into your workflow to run the tests each time you commit to the plugin repository. Learn more about [how to configure tests into your GitHub CI workflow.](https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs)
- **Publish your finished plugin to NPM**: The best way to share and allow others to use your plugin once it is complete is to publish an NPM package. This process is straightforward and well documented, find out more [creating and publishing a NPM package here.](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/).
- **Add payload-plugin topic tag**: Apply the tag **payload-plugin **to your GitHub repository. This will boost the visibility of your plugin and ensure it gets listed with [existing payload plugins](https://github.com/topics/payload-plugin).
- **Use [Semantic Versioning](https://semver.org/) (SemVar)** - With the SemVar system you release version numbers that reflect the nature of changes (major, minor, patch). Ensure all major versions reference their Payload compatibility.
# Questions
Please contact [Payload](mailto:[email protected]) with any questions about using this plugin template.
+2
View File
@@ -0,0 +1,2 @@
DATABASE_URL=mongodb://127.0.0.1/payload-plugin-template
PAYLOAD_SECRET=YOUR_SECRET_HERE
@@ -0,0 +1,25 @@
/* 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 { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
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, importMap, params, searchParams })
export default NotFound
@@ -0,0 +1,25 @@
/* 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 { generatePageMetadata, RootPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
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, importMap, params, searchParams })
export default Page
+9
View File
@@ -0,0 +1,9 @@
import { BeforeDashboardClient as BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343 } from 'ipal-kit/client'
import { BeforeDashboardServer as BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f } from 'ipal-kit/rsc'
export const importMap = {
'ipal-kit/client#BeforeDashboardClient':
BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343,
'ipal-kit/rsc#BeforeDashboardServer':
BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f,
}
+19
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)
@@ -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)
+8
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
+32
View File
@@ -0,0 +1,32 @@
import type { ServerFunctionClient } from 'payload'
import '@payloadcms/next/css'
/* 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 { 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
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.',
})
}
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from '@playwright/test'
// this is an example Playwright e2e test
test('should render admin panel logo', async ({ page }) => {
await page.goto('/admin')
// login
await page.fill('#field-email', '[email protected]')
await page.fill('#field-password', 'test')
await page.click('.form-submit button')
// should show dashboard
await expect(page).toHaveTitle(/Dashboard/)
await expect(page.locator('.graphic-icon')).toBeVisible()
})
+4
View File
@@ -0,0 +1,4 @@
export const devUser = {
email: '[email protected]',
password: 'test',
}
+38
View File
@@ -0,0 +1,38 @@
import type { EmailAdapter, SendEmailOptions } from 'payload'
/**
* Logs all emails to stdout
*/
export const testEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
name: 'test-email-adapter',
defaultFromAddress: '[email protected]',
defaultFromName: 'Payload Test',
sendEmail: async (message) => {
const stringifiedTo = getStringifiedToAddress(message)
const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'`
payload.logger.info({ content: message, msg: res })
return Promise.resolve()
},
})
function getStringifiedToAddress(message: SendEmailOptions): string | undefined {
let stringifiedTo: string | undefined
if (typeof message.to === 'string') {
stringifiedTo = message.to
} else if (Array.isArray(message.to)) {
stringifiedTo = message.to
.map((to: { address: string } | string) => {
if (typeof to === 'string') {
return to
} else if (to.address) {
return to.address
}
return ''
})
.join(', ')
} else if (message.to?.address) {
stringifiedTo = message.to.address
}
return stringifiedTo
}
+52
View File
@@ -0,0 +1,52 @@
import type { Payload } from 'payload'
import config from '@payload-config'
import { createPayloadRequest, getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js'
let payload: Payload
afterAll(async () => {
await payload.destroy()
})
beforeAll(async () => {
payload = await getPayload({ config })
})
describe('Plugin integration tests', () => {
test('should query custom endpoint added by plugin', async () => {
const request = new Request('http://localhost:3000/api/my-plugin-endpoint', {
method: 'GET',
})
const payloadRequest = await createPayloadRequest({ config, request })
const response = await customEndpointHandler(payloadRequest)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toMatchObject({
message: 'Hello from custom endpoint',
})
})
test('can create post with custom text field added by plugin', async () => {
const post = await payload.create({
collection: 'posts',
data: {
addedByPlugin: 'added by plugin',
},
})
expect(post.addedByPlugin).toBe('added by plugin')
})
test('plugin creates and seeds plugin-collection', async () => {
expect(payload.collections['plugin-collection']).toBeDefined()
const { docs } = await payload.find({ collection: 'plugin-collection' })
expect(docs).toHaveLength(1)
})
})
+21
View File
@@ -0,0 +1,21 @@
import { withPayload } from '@payloadcms/next/withPayload'
import { fileURLToPath } from 'url'
import path from 'path'
const dirname = path.dirname(fileURLToPath(import.meta.url))
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
serverExternalPackages: ['mongodb-memory-server'],
}
export default withPayload(nextConfig, { devBundleServerPackages: false })
+276
View File
@@ -0,0 +1,276 @@
/* 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.
*/
export interface Config {
auth: {
users: UserAuthOperations;
};
collections: {
posts: Post;
media: Media;
'plugin-collection': PluginCollection;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'plugin-collection': PluginCollectionSelect<false> | PluginCollectionSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
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` "posts".
*/
export interface Post {
id: string;
addedByPlugin?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: 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` "plugin-collection".
*/
export interface PluginCollection {
id: string;
updatedAt: string;
createdAt: 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;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'plugin-collection';
value: string | PluginCollection;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | 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` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
addedByPlugin?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
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` "plugin-collection_select".
*/
export interface PluginCollectionSelect<T extends boolean = true> {
id?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* 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;
}
/**
* 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 {}
}
+75
View File
@@ -0,0 +1,75 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path'
import { buildConfig } from 'payload'
import { ipalKit } from 'ipal-kit'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed } from './seed.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
if (!process.env.ROOT_DIR) {
process.env.ROOT_DIR = dirname
}
const buildConfigWithMemoryDB = async () => {
if (process.env.NODE_ENV === 'test') {
const memoryDB = await MongoMemoryReplSet.create({
replSet: {
count: 3,
dbName: 'payloadmemory',
},
})
process.env.DATABASE_URL = `${memoryDB.getUri()}&retryWrites=true`
}
return buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
{
slug: 'posts',
fields: [],
},
{
slug: 'media',
fields: [],
upload: {
staticDir: path.resolve(dirname, 'media'),
},
},
],
db: mongooseAdapter({
ensureIndexes: true,
url: process.env.DATABASE_URL || '',
}),
editor: lexicalEditor(),
email: testEmailAdapter,
onInit: async (payload) => {
await seed(payload)
},
plugins: [
ipalKit({
collections: {
posts: true,
},
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
sharp,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
}
export default buildConfigWithMemoryDB()
+21
View File
@@ -0,0 +1,21 @@
import type { Payload } from 'payload'
import { devUser } from './helpers/credentials.js'
export const seed = async (payload: Payload) => {
const { totalDocs } = await payload.count({
collection: 'users',
where: {
email: {
equals: devUser.email,
},
},
})
if (!totalDocs) {
await payload.create({
collection: 'users',
data: devUser,
})
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"extends": "../tsconfig.json",
"exclude": [],
"include": [
"**/*.js",
"**/*.jsx",
"**/*.mjs",
"**/*.cjs",
"**/*.ts",
"**/*.tsx",
"../src/**/*.ts",
"../src/**/*.tsx",
"next.config.mjs",
".next/types/**/*.ts"
],
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@payload-config": [
"./payload.config.ts"
],
"ipal-kit": [
"../src/index.ts"
],
"ipal-kit/client": [
"../src/exports/client.ts"
],
"ipal-kit/rsc": [
"../src/exports/rsc.ts"
]
},
"noEmit": true,
"emitDeclarationOnly": false,
}
}
+46
View File
@@ -0,0 +1,46 @@
// @ts-check
import payloadEsLintConfig from '@payloadcms/eslint-config'
export const defaultESLintIgnores = [
'**/.temp',
'**/.*', // ignore all dotfiles
'**/.git',
'**/.hg',
'**/.pnp.*',
'**/.svn',
'**/playwright.config.ts',
'**/vitest.config.js',
'**/tsconfig.tsbuildinfo',
'**/README.md',
'**/eslint.config.js',
'**/payload-types.ts',
'**/dist/',
'**/.yarn/',
'**/build/',
'**/node_modules/',
'**/temp/',
]
export default [
...payloadEsLintConfig,
{
rules: {
'no-restricted-exports': 'off',
},
},
{
languageOptions: {
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
projectService: {
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
allowDefaultProject: ['scripts/*.ts', '*.js', '*.mjs', '*.spec.ts', '*.d.ts'],
},
// projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+119
View File
@@ -0,0 +1,119 @@
{
"name": "ipal-kit",
"version": "1.0.0",
"description": "A blank template to get started with Payload 3.0",
"license": "MIT",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./client": {
"import": "./src/exports/client.ts",
"types": "./src/exports/client.ts",
"default": "./src/exports/client.ts"
},
"./rsc": {
"import": "./src/exports/rsc.ts",
"types": "./src/exports/rsc.ts",
"default": "./src/exports/rsc.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --outDir dist --rootDir ./src",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"dev": "next dev dev --turbo",
"dev:generate-importmap": "pnpm dev:payload generate:importmap",
"dev:generate-types": "pnpm dev:payload generate:types",
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
"generate:importmap": "pnpm dev:generate-importmap",
"generate:types": "pnpm dev:generate-types",
"lint": "eslint",
"lint:fix": "eslint ./src --fix",
"test": "pnpm test:int && pnpm test:e2e",
"test:e2e": "playwright test",
"test:int": "vitest"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-mongodb": "3.84.1",
"@payloadcms/db-postgres": "3.84.1",
"@payloadcms/db-sqlite": "3.84.1",
"@payloadcms/eslint-config": "3.28.0",
"@payloadcms/next": "3.84.1",
"@payloadcms/richtext-lexical": "3.84.1",
"@payloadcms/ui": "3.84.1",
"@playwright/test": "1.58.2",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.6.0",
"@types/node": "22.19.9",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"copyfiles": "2.4.1",
"cross-env": "^7.0.3",
"eslint": "^9.23.0",
"eslint-config-next": "16.2.6",
"graphql": "^16.8.1",
"mongodb-memory-server": "10.1.4",
"next": "16.2.6",
"open": "^10.1.0",
"payload": "3.84.1",
"prettier": "^3.4.2",
"qs-esm": "8.0.1",
"react": "19.2.6",
"react-dom": "19.2.6",
"rimraf": "3.0.2",
"sharp": "0.34.2",
"sort-package-json": "^2.10.0",
"typescript": "5.7.3",
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18"
},
"peerDependencies": {
"payload": "^3.84.1"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",
"pnpm": "^9 || ^10 || ^11"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./client": {
"import": "./dist/exports/client.js",
"types": "./dist/exports/client.d.ts",
"default": "./dist/exports/client.js"
},
"./rsc": {
"import": "./dist/exports/rsc.js",
"types": "./dist/exports/rsc.d.ts",
"default": "./dist/exports/rsc.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"esbuild",
"unrs-resolver"
]
},
"registry": "https://registry.npmjs.org/",
"dependencies": {}
}
+46
View File
@@ -0,0 +1,46 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './dev',
testMatch: '**/e2e.spec.{ts,js}',
/* Run tests in files in parallel */
fullyParallel: true,
/* 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. */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
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',
},
webServer: {
command: 'pnpm dev',
reuseExistingServer: true,
url: 'http://localhost:3000/admin',
},
})
+11854
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
'use client'
import { useConfig } from '@payloadcms/ui'
import { formatAdminURL } from 'payload/shared'
import { useEffect, useState } from 'react'
export const BeforeDashboardClient = () => {
const { config } = useConfig()
const [message, setMessage] = useState('')
useEffect(() => {
const fetchMessage = async () => {
const response = await fetch(
formatAdminURL({
apiRoute: config.routes.api,
path: '/my-plugin-endpoint',
}),
)
const result = await response.json()
setMessage(result.message)
}
void fetchMessage()
}, [config.serverURL, config.routes.api])
return (
<div>
<h1>Added by the plugin: Before Dashboard Client</h1>
<div>
Message from the endpoint:
<div>{message || 'Loading...'}</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
.wrapper {
display: flex;
gap: 5px;
flex-direction: column;
}
+19
View File
@@ -0,0 +1,19 @@
import type { ServerComponentProps } from 'payload'
import styles from './BeforeDashboardServer.module.css'
export const BeforeDashboardServer = async (props: ServerComponentProps) => {
const { payload } = props
const { docs } = await payload.find({ collection: 'plugin-collection' })
return (
<div className={styles.wrapper}>
<h1>Added by the plugin: Before Dashboard Server</h1>
Docs from Local API:
{docs.map((doc) => (
<div key={doc.id}>{doc.id}</div>
))}
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import type { PayloadHandler } from 'payload'
export const customEndpointHandler: PayloadHandler = () => {
return Response.json({ message: 'Hello from custom endpoint' })
}
+1
View File
@@ -0,0 +1 @@
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'
+1
View File
@@ -0,0 +1 @@
export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js'
+113
View File
@@ -0,0 +1,113 @@
import type { CollectionSlug, Config } from 'payload'
import { customEndpointHandler } from './endpoints/customEndpointHandler.js'
export type IpalKitConfig = {
/**
* List of collections to add a custom field
*/
collections?: Partial<Record<CollectionSlug, true>>
disabled?: boolean
}
export const ipalKit =
(pluginOptions: IpalKitConfig) =>
(config: Config): Config => {
if (!config.collections) {
config.collections = []
}
config.collections.push({
slug: 'plugin-collection',
fields: [
{
name: 'id',
type: 'text',
},
],
})
if (pluginOptions.collections) {
for (const collectionSlug in pluginOptions.collections) {
const collection = config.collections.find(
(collection) => collection.slug === collectionSlug,
)
if (collection) {
collection.fields.push({
name: 'addedByPlugin',
type: 'text',
admin: {
position: 'sidebar',
},
})
}
}
}
/**
* If the plugin is disabled, we still want to keep added collections/fields so the database schema is consistent which is important for migrations.
* If your plugin heavily modifies the database schema, you may want to remove this property.
*/
if (pluginOptions.disabled) {
return config
}
if (!config.endpoints) {
config.endpoints = []
}
if (!config.admin) {
config.admin = {}
}
if (!config.admin.components) {
config.admin.components = {}
}
if (!config.admin.components.beforeDashboard) {
config.admin.components.beforeDashboard = []
}
config.admin.components.beforeDashboard.push(
`ipal-kit/client#BeforeDashboardClient`,
)
config.admin.components.beforeDashboard.push(
`ipal-kit/rsc#BeforeDashboardServer`,
)
config.endpoints.push({
handler: customEndpointHandler,
method: 'get',
path: '/my-plugin-endpoint',
})
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
// Ensure we are executing any existing onInit functions before running our own.
if (incomingOnInit) {
await incomingOnInit(payload)
}
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
}
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"rootDir": "./",
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"esModuleInterop": true,
"module": "NodeNext",
"moduleResolution": "nodenext",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"emitDeclarationOnly": true,
"target": "ES2022",
"composite": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"./src/**/*.ts",
"./src/**/*.tsx",
"./dev/next-env.d.ts",
".next/dev/types/**/*.ts"
]
}
+25
View File
@@ -0,0 +1,25 @@
import path from 'path'
import { loadEnv } from 'payload/node'
import { fileURLToPath } from 'url'
import tsconfigPaths from 'vite-tsconfig-paths'
import { defineConfig } from 'vitest/config'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default defineConfig(() => {
loadEnv(path.resolve(dirname, './dev'))
return {
plugins: [
tsconfigPaths({
ignoreConfigErrors: true,
}),
],
test: {
environment: 'node',
hookTimeout: 30_000,
testTimeout: 30_000,
},
}
})