Initial commit

This commit is contained in:
Jason Fraley 2026-04-21 18:17:12 -04:00
commit e6225be5eb
292 changed files with 22030 additions and 0 deletions

View file

@ -0,0 +1,57 @@
# Payload Skill for AI Coding Agents
Agent skill providing comprehensive guidance for Payload development with TypeScript patterns, field configurations, hooks, access control, and API examples.
## What's Included
The `payload` skill provides expert guidance on:
- **Collections**: Auth, uploads, drafts, live preview configurations
- **Fields**: All field types including relationships, arrays, blocks, joins, virtual fields
- **Hooks**: beforeChange, afterChange, beforeValidate, field hooks
- **Access Control**: Collection, field, and global access patterns including RBAC and multi-tenant
- **Queries**: Local API, REST, and GraphQL with complex operators
- **Database Adapters**: MongoDB, Postgres, SQLite configurations and transactions
- **Advanced Features**: Jobs queue, custom endpoints, localization, plugins
## Usage
Once installed, the Agent will automatically invoke the skill when you're working on Payload CMS projects. The skill activates when you:
- Edit `payload.config.ts` files
- Work with collection or global configurations
- Ask about Payload-specific patterns
- Need guidance on fields, hooks, or access control
You can also explicitly invoke it:
```
@payload how do I implement row-level access control?
```
## Documentation Structure
```
skills/payload/
├── SKILL.md # Main skill file with quick reference
└── reference/
├── FIELDS.md # All field types and configurations
├── COLLECTIONS.md # Collection patterns
├── HOOKS.md # Hook patterns and examples
├── ACCESS-CONTROL.md # Basic access control
├── ACCESS-CONTROL-ADVANCED.md # Advanced access patterns
├── QUERIES.md # Query patterns and APIs
├── ADAPTERS.md # Database and storage adapters
└── ADVANCED.md # Jobs, endpoints, localization
```
## Resources
- [Payload Documentation](https://payloadcms.com/docs)
- [GitHub Repository](https://github.com/payloadcms/payload)
- [Examples](https://github.com/payloadcms/payload/tree/main/examples)
- [Templates](https://github.com/payloadcms/payload/tree/main/templates)
## License
MIT

View file

@ -0,0 +1,409 @@
---
name: payload
description: Use when working with Payload 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 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#text-field](reference/FIELDS.md#text-field) |
| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) |
| Select specific fields | `select` parameter | [QUERIES.md#local-api](reference/QUERIES.md#local-api) |
| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
| Prevent hook loops | `req.context` check | [HOOKS.md#hook-context](reference/HOOKS.md#hook-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>
}
```
### Logger Usage
```ts
// ✅ Valid: single string
payload.logger.error('Something went wrong')
// ✅ Valid: object with msg and err
payload.logger.error({ msg: 'Failed to process', err: error })
// ❌ Invalid: don't pass error as second argument
payload.logger.error('Failed to process', error)
// ❌ Invalid: use `err` not `error`, use `msg` not `message`
payload.logger.error({ message: 'Failed', error: error })
```
## 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'
```
## 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/main/examples>
- Templates: <https://github.com/payloadcms/payload/tree/main/templates>

View file

@ -0,0 +1,704 @@
# Payload 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

View file

@ -0,0 +1,697 @@
# Payload 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).

View file

@ -0,0 +1,326 @@
# Payload 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: 'noreply@example.com',
defaultFromName: 'My App',
transportOptions: {
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
}),
})
```
### Resend
```ts
import { resendAdapter } from '@payloadcms/email-resend'
export default buildConfig({
email: resendAdapter({
defaultFromAddress: 'noreply@example.com',
defaultFromName: 'My App',
apiKey: process.env.RESEND_API_KEY,
}),
})
```

View file

@ -0,0 +1,386 @@
# Payload 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: 'user@example.com',
password: 'password',
}),
})
// Local API
const result = await payload.login({
collection: 'users',
data: {
email: 'user@example.com',
password: 'password',
},
})
```
### Forgot Password
```ts
await payload.forgotPassword({
collection: 'users',
data: {
email: 'user@example.com',
},
})
```
### 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: 'user@example.com',
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/main/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/main/packages/payload/src/database/types.ts)** - Base adapter interface
- **[MongoDB Adapter](https://github.com/payloadcms/payload/blob/main/packages/db-mongodb/src/index.ts)** - MongoDB-specific options
- **[Postgres Adapter](https://github.com/payloadcms/payload/blob/main/packages/db-postgres/src/index.ts)** - Postgres-specific options
### Rich Text & Plugins
- **[Lexical Types](https://github.com/payloadcms/payload/blob/main/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.

View file

@ -0,0 +1,303 @@
# Payload 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',
},
],
},
],
}
```

View file

@ -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>

View file

@ -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
}
}
```

View file

@ -0,0 +1,744 @@
# Payload 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.

View file

@ -0,0 +1,186 @@
# Payload 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

View file

@ -0,0 +1,274 @@
# Payload 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`

0
.ai/mcp/mcp.json Normal file
View file

140
.gitignore vendored Normal file
View file

@ -0,0 +1,140 @@
### Node template
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Example user template template
### Example user template
# IntelliJ project files
.idea
*.iml
out
gen

1
.goose/skills/payload Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/payload

1
.junie/skills/payload Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/payload

3
.npmrc Normal file
View file

@ -0,0 +1,3 @@
@awesome.me:registry=https://npm.fontawesome.com/
@fortawesome:registry=https://npm.fontawesome.com/
//npm.fontawesome.com/:_authToken=1D8D0CE8-B98B-4F81-ABE4-49ECC1533CD4

4
AGENTS.md Normal file
View file

@ -0,0 +1,4 @@
# Agents
This project uses the Payload CMS skill at `.agents/skills/payload/`.
Start with `.agents/skills/payload/SKILL.md` for a quick reference, then see `.agents/skills/payload/reference/` for detailed docs.

71
Dockerfile Normal file
View file

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

303
README.md Normal file
View file

@ -0,0 +1,303 @@
# Payload Website Template
This is the official [Payload Website Template](https://github.com/payloadcms/payload/blob/main/templates/website). Use it to power websites, blogs, or portfolios from small to enterprise. This repo includes a fully-working backend, enterprise-grade admin panel, and a beautifully designed, production-ready website.
This template is right for you if you are working on:
- A personal or enterprise-grade website, blog, or portfolio
- A content publishing platform with a fully featured publication workflow
- Exploring the capabilities of Payload
Core features:
- [Pre-configured Payload Config](#how-it-works)
- [Authentication](#users-authentication)
- [Access Control](#access-control)
- [Layout Builder](#layout-builder)
- [Draft Preview](#draft-preview)
- [Live Preview](#live-preview)
- [On-demand Revalidation](#on-demand-revalidation)
- [SEO](#seo)
- [Search](#search)
- [Redirects](#redirects)
- [Jobs and Scheduled Publishing](#jobs-and-scheduled-publish)
- [Website](#website)
## Quick Start
To spin up this example locally, follow these steps:
### Clone
If you have not done so already, you need to have standalone copy of this repo on your machine. If you've already cloned this repo, skip to [Development](#development).
Use the `create-payload-app` CLI to clone this template directly to your machine:
```bash
pnpx create-payload-app my-project -t website
```
### Development
1. First [clone the repo](#clone) if you have not done so already
1. `cd my-project && cp .env.example .env` to copy the example environment variables
1. `pnpm install && pnpm dev` to install dependencies and start the dev server
1. open `http://localhost:3000` to open the app in your browser
That's it! Changes made in `./src` will be reflected in your app. Follow the on-screen instructions to login and create your first admin user. Then check out [Production](#production) once you're ready to build and serve your app, and [Deployment](#deployment) when you're ready to go live.
## How it works
The Payload config is tailored specifically to the needs of most websites. It is pre-configured in the following ways:
### Collections
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend this functionality.
- #### Users (Authentication)
Users are auth-enabled collections that have access to the admin panel and unpublished content. See [Access Control](#access-control) for more details.
For additional help, see the official [Auth Example](https://github.com/payloadcms/payload/tree/main/examples/auth) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs.
- #### Posts
Posts are used to generate blog posts, news articles, or any other type of content that is published over time. All posts are layout builder enabled so you can generate unique layouts for each post using layout-building blocks, see [Layout Builder](#layout-builder) for more details. Posts are also draft-enabled so you can preview them before publishing them to your website, see [Draft Preview](#draft-preview) for more details.
- #### Pages
All pages are layout builder enabled so you can generate unique layouts for each page using layout-building blocks, see [Layout Builder](#layout-builder) for more details. Pages are also draft-enabled so you can preview them before publishing them to your website, see [Draft Preview](#draft-preview) for more details.
- #### Media
This is the uploads enabled collection used by pages, posts, and projects to contain media like images, videos, downloads, and other assets. It features pre-configured sizes, focal point and manual resizing to help you manage your pictures.
- #### Categories
A taxonomy used to group posts together. Categories can be nested inside of one another, for example "News > Technology". See the official [Payload Nested Docs Plugin](https://payloadcms.com/docs/plugins/nested-docs) for more details.
### Globals
See the [Globals](https://payloadcms.com/docs/configuration/globals) docs for details on how to extend this functionality.
- `Header`
The data required by the header on your front-end like nav links.
- `Footer`
Same as above but for the footer of your site.
## Access control
Basic access control is setup to limit access to various content based based on publishing status.
- `users`: Users can access the admin panel and create or edit content.
- `posts`: Everyone can access published posts, but only users can create, update, or delete them.
- `pages`: Everyone can access published pages, but only users can create, update, or delete them.
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview#access-control) docs.
## Layout Builder
Create unique page layouts for any type of content using a powerful layout builder. This template comes pre-configured with the following layout building blocks:
- Hero
- Content
- Media
- Call To Action
- Archive
Each block is fully designed and built into the front-end website that comes with this template. See [Website](#website) for more details.
## Lexical editor
A deep editorial experience that allows complete freedom to focus just on writing content without breaking out of the flow with support for Payload blocks, media, links and other features provided out of the box. See [Lexical](https://payloadcms.com/docs/rich-text/overview) docs.
## Draft Preview
All posts and pages are draft-enabled so you can preview them before publishing them to your website. To do this, these collections use [Versions](https://payloadcms.com/docs/configuration/collections#versions) with `drafts` set to `true`. This means that when you create a new post, project, or page, it will be saved as a draft and will not be visible on your website until you publish it. This also means that you can preview your draft before publishing it to your website. To do this, we automatically format a custom URL which redirects to your front-end to securely fetch the draft version of your content.
Since the front-end of this template is statically generated, this also means that pages, posts, and projects will need to be regenerated as changes are made to published documents. To do this, we use an `afterChange` hook to regenerate the front-end when a document has changed and its `_status` is `published`.
For more details on how to extend this functionality, see the official [Draft Preview Example](https://github.com/payloadcms/payload/tree/main/examples/draft-preview).
## Live preview
In addition to draft previews you can also enable live preview to view your end resulting page as you're editing content with full support for SSR rendering. See [Live preview docs](https://payloadcms.com/docs/live-preview/overview) for more details.
## On-demand Revalidation
We've added hooks to collections and globals so that all of your pages, posts, footer, or header changes will automatically be updated in the frontend via on-demand revalidation supported by Nextjs.
> Note: if an image has been changed, for example it's been cropped, you will need to republish the page it's used on in order to be able to revalidate the Nextjs image cache.
## SEO
This template comes pre-configured with the official [Payload SEO Plugin](https://payloadcms.com/docs/plugins/seo) for complete SEO control from the admin panel. All SEO data is fully integrated into the front-end website that comes with this template. See [Website](#website) for more details.
## Search
This template also pre-configured with the official [Payload Search Plugin](https://payloadcms.com/docs/plugins/search) to showcase how SSR search features can easily be implemented into Next.js with Payload. See [Website](#website) for more details.
## Redirects
If you are migrating an existing site or moving content to a new URL, you can use the `redirects` collection to create a proper redirect from old URLs to new ones. This will ensure that proper request status codes are returned to search engines and that your users are not left with a broken link. This template comes pre-configured with the official [Payload Redirects Plugin](https://payloadcms.com/docs/plugins/redirects) for complete redirect control from the admin panel. All redirects are fully integrated into the front-end website that comes with this template. See [Website](#website) for more details.
## Jobs and Scheduled Publish
We have configured [Scheduled Publish](https://payloadcms.com/docs/versions/drafts#scheduled-publish) which uses the [jobs queue](https://payloadcms.com/docs/jobs-queue/jobs) in order to publish or unpublish your content on a scheduled time. The tasks are run on a cron schedule and can also be run as a separate instance if needed.
> Note: When deployed on Vercel, depending on the plan tier, you may be limited to daily cron only.
## Website
This template includes a beautifully designed, production-ready front-end built with the [Next.js App Router](https://nextjs.org), served right alongside your Payload app in a instance. This makes it so that you can deploy both your backend and website where you need it.
Core features:
- [Next.js App Router](https://nextjs.org)
- [TypeScript](https://www.typescriptlang.org)
- [React Hook Form](https://react-hook-form.com)
- [Payload Admin Bar](https://github.com/payloadcms/payload/tree/main/packages/admin-bar)
- [TailwindCSS styling](https://tailwindcss.com/)
- [shadcn/ui components](https://ui.shadcn.com/)
- User Accounts and Authentication
- Fully featured blog
- Publication workflow
- Dark mode
- Pre-made layout building blocks
- SEO
- Search
- Redirects
- Live preview
### Cache
Although Next.js includes a robust set of caching strategies out of the box, Payload Cloud proxies and caches all files through Cloudflare using the [Official Cloud Plugin](https://www.npmjs.com/package/@payloadcms/payload-cloud). This means that Next.js caching is not needed and is disabled by default. If you are hosting your app outside of Payload Cloud, you can easily reenable the Next.js caching mechanisms by removing the `no-store` directive from all fetch requests in `./src/app/_api` and then removing all instances of `export const dynamic = 'force-dynamic'` from pages files, such as `./src/app/(pages)/[slug]/page.tsx`. For more details, see the official [Next.js Caching Docs](https://nextjs.org/docs/app/building-your-application/caching).
## Development
To spin up this example locally, follow the [Quick Start](#quick-start). Then [Seed](#seed) the database with a few pages, posts, and projects.
### Working with Postgres
Postgres and other SQL-based databases follow a strict schema for managing your data. In comparison to our MongoDB adapter, this means that there's a few extra steps to working with Postgres.
Note that often times when making big schema changes you can run the risk of losing data if you're not manually migrating it.
#### Local development
Ideally we recommend running a local copy of your database so that schema updates are as fast as possible. By default the Postgres adapter has `push: true` for development environments. This will let you add, modify and remove fields and collections without needing to run any data migrations.
If your database is pointed to production you will want to set `push: false` otherwise you will risk losing data or having your migrations out of sync.
#### Migrations
[Migrations](https://payloadcms.com/docs/database/migrations) are essentially SQL code versions that keeps track of your schema. When deploy with Postgres you will need to make sure you create and then run your migrations.
Locally create a migration
```bash
pnpm payload migrate:create
```
This creates the migration files you will need to push alongside with your new configuration.
On the server after building and before running `pnpm start` you will want to run your migrations
```bash
pnpm payload migrate
```
This command will check for any migrations that have not yet been run and try to run them and it will keep a record of migrations that have been run in the database.
### Docker
Alternatively, you can use [Docker](https://www.docker.com) to spin up this template locally. To do so, follow these steps:
1. Follow [steps 1 and 2 from above](#development), the docker-compose file will automatically use the `.env` file in your project root
1. Next run `docker-compose up`
1. Follow [steps 4 and 5 from above](#development) to login and create your first admin user
That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.
### Seed
To seed the database with a few pages, posts, and projects you can click the 'seed database' link from the admin panel.
The seed script will also create a demo user for demonstration purposes only:
- Demo Author
- Email: `demo-author@payloadcms.com`
- Password: `password`
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
## Production
To run Payload in production, you need to build and start the Admin panel. To do so, follow these steps:
1. Invoke the `next build` script by running `pnpm build` or `npm run build` in your project root. This creates a `.next` directory with a production-ready admin bundle.
1. Finally run `pnpm start` or `npm run start` to run Node in production and serve Payload from the `.build` directory.
1. When you're ready to go live, see Deployment below for more details.
### Deploying to Vercel
This template can also be deployed to Vercel for free. You can get started by choosing the Vercel DB adapter during the setup of the template or by manually installing and configuring it:
```bash
pnpm add @payloadcms/db-vercel-postgres
```
```ts
// payload.config.ts
import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'
export default buildConfig({
// ...
db: vercelPostgresAdapter({
pool: {
connectionString: process.env.POSTGRES_URL || '',
},
}),
// ...
```
We also support Vercel's blob storage:
```bash
pnpm add @payloadcms/storage-vercel-blob
```
```ts
// payload.config.ts
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
export default buildConfig({
// ...
plugins: [
vercelBlobStorage({
collections: {
[Media.slug]: true,
},
token: process.env.BLOB_READ_WRITE_TOKEN || '',
}),
],
// ...
```
There is also a simplified [one click deploy](https://github.com/payloadcms/payload/tree/templates/with-vercel-postgres) to Vercel should you need it.
### Self-hosting
Before deploying your app, you need to:
1. Ensure your app builds and serves in production. See [Production](#production) for more details.
2. You can then deploy Payload as you would any other Node.js or Next.js application either directly on a VPS, DigitalOcean's Apps Platform, via Coolify or more. More guides coming soon.
You can also deploy your app manually, check out the [deployment documentation](https://payloadcms.com/docs/production/deployment) for full details.
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

2137
bun.lock Normal file

File diff suppressed because it is too large Load diff

0
bunfig.toml Normal file
View file

17
components.json Normal file
View file

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.mjs",
"css": "src/app/(frontend)/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/utilities/ui"
}
}

31
docker-compose.yml Normal file
View file

@ -0,0 +1,31 @@
version: '3'
services:
payload:
image: node:18-alpine
ports:
- '3000:3000'
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "yarn install && yarn dev"
depends_on:
- mongo
env_file:
- .env
mongo:
image: mongo:latest
ports:
- '27017:27017'
command:
- --storageEngine=wiredTiger
volumes:
- data:/data/db
logging:
driver: none
volumes:
data:
node_modules:

38
eslint.config.mjs Normal file
View file

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

6
next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

20
next-sitemap.config.cjs Normal file
View file

@ -0,0 +1,20 @@
const SITE_URL =
process.env.NEXT_PUBLIC_SERVER_URL ||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
'https://example.com'
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: SITE_URL,
generateRobotsTxt: true,
exclude: ['/posts-sitemap.xml', '/pages-sitemap.xml', '/*', '/posts/*'],
robotsTxtOptions: {
policies: [
{
userAgent: '*',
disallow: '/admin/*',
},
],
additionalSitemaps: [`${SITE_URL}/pages-sitemap.xml`, `${SITE_URL}/posts-sitemap.xml`],
},
}

54
next.config.ts Normal file
View file

@ -0,0 +1,54 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(__filename)
import { redirects } from './redirects'
const NEXT_PUBLIC_SERVER_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: process.env.__NEXT_PRIVATE_ORIGIN || 'http://localhost:3000'
const nextConfig: NextConfig = {
// Temporarily required on Windows until Next.js fixes Turbopack Sass resolution.
// See: https://github.com/vercel/next.js/issues/86431
sassOptions: {
loadPaths: ['./node_modules/@payloadcms/ui/dist/scss/'],
},
images: {
localPatterns: [
{
pathname: '/api/media/file/**',
},
],
qualities: [100],
remotePatterns: [
...[NEXT_PUBLIC_SERVER_URL /* 'https://example.com' */].map((item) => {
const url = new URL(item)
return {
hostname: url.hostname,
protocol: url.protocol.replace(':', '') as 'http' | 'https',
}
}),
],
},
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
reactStrictMode: true,
redirects,
turbopack: {
root: path.resolve(dirname),
},
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

94
package.json Normal file
View file

@ -0,0 +1,94 @@
{
"name": "onyx-simple",
"version": "1.0.0",
"description": "Website template for Payload",
"license": "MIT",
"type": "module",
"scripts": {
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"postbuild": "next-sitemap --config next-sitemap.config.cjs",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && pnpm build && pnpm start",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install",
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint .",
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation eslint . --fix",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"test": "pnpm run test:int && pnpm run test:e2e",
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
},
"dependencies": {
"@awesome.me/kit-3dc4522338": "^1.0.4",
"@fortawesome/react-fontawesome": "^3.3.1",
"@payloadcms/admin-bar": "3.83.0",
"@payloadcms/db-postgres": "3.83.0",
"@payloadcms/live-preview-react": "3.83.0",
"@payloadcms/next": "3.83.0",
"@payloadcms/plugin-form-builder": "3.83.0",
"@payloadcms/plugin-nested-docs": "3.83.0",
"@payloadcms/plugin-redirects": "3.83.0",
"@payloadcms/plugin-search": "3.83.0",
"@payloadcms/plugin-seo": "3.83.0",
"@payloadcms/richtext-lexical": "3.83.0",
"@payloadcms/ui": "3.83.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cross-env": "^7.0.3",
"dotenv": "16.4.7",
"geist": "^1.3.0",
"graphql": "^16.8.2",
"lucide-react": "0.563.0",
"next": "16.2.3",
"next-sitemap": "^4.2.3",
"payload": "3.83.0",
"prism-react-renderer": "^2.3.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "7.71.1",
"sharp": "0.34.2",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@playwright/test": "1.58.2",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/react": "16.3.0",
"@types/escape-html": "^1.0.2",
"@types/node": "22.19.9",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "4.5.2",
"autoprefixer": "^10.4.19",
"eslint": "^9.16.0",
"eslint-config-next": "16.2.3",
"jsdom": "28.0.0",
"postcss": "^8.4.38",
"prettier": "^3.4.2",
"tailwindcss": "^4.1.18",
"tsx": "4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "5.7.3",
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",
"pnpm": "^9 || ^10"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"esbuild",
"unrs-resolver"
]
}
}

41
playwright.config.ts Normal file
View file

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

7
postcss.config.js Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

23
public/favicon.svg Normal file
View file

@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><style>
#light-icon {
display: inline;
}
#dark-icon {
display: none;
}
@media (prefers-color-scheme: dark) {
#light-icon {
display: none;
}
#dark-icon {
display: inline;
}
}
</style><g id="light-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1059)"><rect width="1000" height="1000" fill="#000000"></rect><g transform="matrix(5,0,0,5,192.5,150)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="123" height="140"><svg width="123" height="140" viewBox="0 0 123 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M60.2569 118.758L18.9035 94.9917C18.4016 94.6917 18.067 94.1583 18.067 93.5583V56.825C18.067 56.1917 18.7696 55.7917 19.3049 56.0917L67.3164 83.6917C67.9855 84.0917 68.822 83.5917 68.822 82.825V64.925C68.822 64.225 68.4539 63.5583 67.8182 63.1917L10.0707 29.9917C9.56883 29.6917 8.89968 29.6917 8.39782 29.9917L0.836436 34.3583C0.334574 34.6583 0 35.1917 0 35.7917V104.025C0 104.625 0.334574 105.158 0.836436 105.458L60.1565 139.592C60.6583 139.892 61.3275 139.892 61.8293 139.592L111.647 110.925C112.317 110.525 112.317 109.592 111.647 109.192L96.1232 100.258C95.4875 99.8917 94.7515 99.8917 94.1158 100.258L61.9632 118.758C61.4613 119.058 60.7922 119.058 60.2903 118.758H60.2569Z" fill="white"></path>
<path d="M121.149 34.325L61.8294 0.225C61.3275 -0.075 60.6584 -0.075 60.1565 0.225L28.8069 18.2583C28.1378 18.6583 28.1378 19.5917 28.8069 19.9917L44.1973 28.8583C44.833 29.225 45.5691 29.225 46.2048 28.8583L60.2569 20.7917C60.7588 20.4917 61.4279 20.4917 61.9298 20.7917L103.283 44.5583C103.785 44.8583 104.12 45.3917 104.12 45.9917V82.8917C104.12 83.5917 104.488 84.2583 105.123 84.625L120.514 93.4583C121.183 93.8583 122.019 93.3583 122.019 92.5917V35.7917C122.019 35.1917 121.685 34.6583 121.183 34.3583L121.149 34.325Z" fill="white"></path>
</svg></svg></g></g><defs><clipPath id="SvgjsClipPath1059"><rect width="1000" height="1000" x="0" y="0" rx="350" ry="350"></rect></clipPath></defs></svg></g><g id="dark-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1060)"><rect width="1000" height="1000" fill="#000000"></rect><g transform="matrix(5,0,0,5,192.5,150)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="123" height="140"><svg width="123" height="140" viewBox="0 0 123 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M60.2569 118.758L18.9035 94.9917C18.4016 94.6917 18.067 94.1583 18.067 93.5583V56.825C18.067 56.1917 18.7696 55.7917 19.3049 56.0917L67.3164 83.6917C67.9855 84.0917 68.822 83.5917 68.822 82.825V64.925C68.822 64.225 68.4539 63.5583 67.8182 63.1917L10.0707 29.9917C9.56883 29.6917 8.89968 29.6917 8.39782 29.9917L0.836436 34.3583C0.334574 34.6583 0 35.1917 0 35.7917V104.025C0 104.625 0.334574 105.158 0.836436 105.458L60.1565 139.592C60.6583 139.892 61.3275 139.892 61.8293 139.592L111.647 110.925C112.317 110.525 112.317 109.592 111.647 109.192L96.1232 100.258C95.4875 99.8917 94.7515 99.8917 94.1158 100.258L61.9632 118.758C61.4613 119.058 60.7922 119.058 60.2903 118.758H60.2569Z" fill="white"></path>
<path d="M121.149 34.325L61.8294 0.225C61.3275 -0.075 60.6584 -0.075 60.1565 0.225L28.8069 18.2583C28.1378 18.6583 28.1378 19.5917 28.8069 19.9917L44.1973 28.8583C44.833 29.225 45.5691 29.225 46.2048 28.8583L60.2569 20.7917C60.7588 20.4917 61.4279 20.4917 61.9298 20.7917L103.283 44.5583C103.785 44.8583 104.12 45.3917 104.12 45.9917V82.8917C104.12 83.5917 104.488 84.2583 105.123 84.625L120.514 93.4583C121.183 93.8583 122.019 93.3583 122.019 92.5917V35.7917C122.019 35.1917 121.685 34.6583 121.183 34.3583L121.149 34.325Z" fill="white"></path>
</svg></svg></g></g><defs><clipPath id="SvgjsClipPath1060"><rect width="1000" height="1000" x="0" y="0" rx="350" ry="350"></rect></clipPath></defs></svg></g></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

48
public/logo-duotone.svg Normal file
View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="300"
height="300"
viewBox="0 0 300 300"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
sodipodi:docname="logo-duotone.svg"
inkscape:export-filename="logo.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1.3616667"
inkscape:cx="43.329253"
inkscape:cy="188.37209"
inkscape:window-width="1920"
inkscape:window-height="1004"
inkscape:window-x="5120"
inkscape:window-y="1155"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><path
id="path4"
style="opacity:1;fill:#000000;fill-opacity:1;stroke:#e5e5e5;stroke-width:19.3432;stroke-opacity:1;paint-order:stroke fill markers"
d="M 151.41726,14.254382 68.353211,98.763114 49.484507,117.96127 C 17.587641,150.41376 103.57164,219.08656 149.0742,218.78322 193.90971,218.48435 281.65267,150.3557 250.65406,117.96127 L 232.28402,98.763114 Z m 0,75.133318 c 19.77118,5.03e-4 35.79827,15.39296 35.79704,34.37957 -5.3e-4,18.98542 -16.0271,34.37602 -35.79704,34.37652 -19.77113,0.001 -35.79956,-15.38996 -35.80009,-34.37652 -0.001,-18.98775 16.02772,-34.380685 35.80009,-34.37957 z"
sodipodi:nodetypes="cccscccccccc" /><path
style="opacity:1;fill:#000000;stroke:#e5e5e5;stroke-width:19;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers;fill-opacity:1;stroke-opacity:1;stroke-dashoffset:0;stroke-miterlimit:4"
d="m 32.039343,171.06602 12.47681,-13.25706 c 0,0 64.212437,61.16558 104.558047,60.97426 40.6619,-0.19283 104.79014,-62.44893 104.79014,-62.44893 0,0 9.39756,9.53569 14.09635,14.30351 C 227.4057,209.48538 149.0742,289.96628 149.0742,289.96628 Z"
id="path1" /></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

48
public/logo-white.svg Normal file
View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="300"
height="300"
viewBox="0 0 300 300"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
sodipodi:docname="logo-white.svg"
inkscape:export-filename="logo.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#000000"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1.3616667"
inkscape:cx="2.2031823"
inkscape:cy="163.40269"
inkscape:window-width="1920"
inkscape:window-height="1004"
inkscape:window-x="5120"
inkscape:window-y="1155"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><path
id="path4"
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:19.3432;stroke-opacity:1;paint-order:stroke fill markers"
d="M 151.41726,14.254382 68.353211,98.763114 49.484507,117.96127 c -4.968354,39.84769 58.229493,86.80098 99.589693,86.80098 41.3602,0 104.79014,-48.42796 101.57986,-86.80098 L 232.28402,98.763114 Z m 0,75.133318 c 19.77118,5.03e-4 35.79827,15.39296 35.79704,34.37957 -5.3e-4,18.98542 -16.0271,34.37602 -35.79704,34.37652 -19.77113,0.001 -35.79956,-15.38996 -35.80009,-34.37652 -0.001,-18.98775 16.02772,-34.380685 35.80009,-34.37957 z"
sodipodi:nodetypes="ccczcccccccc" /><path
style="opacity:1;fill:#ffffff;stroke:none;stroke-width:19;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers;fill-opacity:1;stroke-opacity:1;stroke-dashoffset:0;stroke-miterlimit:4"
d="m 32.039343,171.06602 12.47681,-13.25706 c 0,0 64.212437,61.16558 104.558047,60.97426 40.6619,-0.19283 104.79014,-62.44893 104.79014,-62.44893 0,0 9.39756,9.53569 14.09635,14.30351 C 227.4057,209.48538 149.0742,289.96628 149.0742,289.96628 Z"
id="path1" /></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Some files were not shown because too many files have changed in this diff Show more