From 7c999faf97061dc3b2088ec1266d6032f5fcaf29 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 21 Nov 2025 16:51:00 -0500 Subject: [PATCH 1/7] chore(claude): add field type guards --- tools/claude-plugin/skills/payload/SKILL.md | 2 + .../payload/reference/FIELD-TYPE-GUARDS.md | 560 ++++++++++++++++++ .../skills/payload/reference/FIELDS.md | 44 ++ 3 files changed, 606 insertions(+) create mode 100644 tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md diff --git a/tools/claude-plugin/skills/payload/SKILL.md b/tools/claude-plugin/skills/payload/SKILL.md index 55b4b5b0144..88f3eb983fd 100644 --- a/tools/claude-plugin/skills/payload/SKILL.md +++ b/tools/claude-plugin/skills/payload/SKILL.md @@ -31,6 +31,7 @@ Payload 3.x is a Next.js native CMS with TypeScript-first architecture, providin | 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 @@ -196,6 +197,7 @@ 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 diff --git a/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md b/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md new file mode 100644 index 00000000000..25525f75c63 --- /dev/null +++ b/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md @@ -0,0 +1,560 @@ +# 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( + 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( + 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( + 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( + 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( + 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( + 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 +``` + +**⚠️ Use Instead of `fieldIsLocalized`:** + +```ts +// ❌ DEPRECATED - doesn't account for parent localization +if (fieldIsLocalized(field)) { + /* ... */ +} + +// ✅ CORRECT - 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( + 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( + 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( + 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( + 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( + 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( + 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): 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 + } +} +``` diff --git a/tools/claude-plugin/skills/payload/reference/FIELDS.md b/tools/claude-plugin/skills/payload/reference/FIELDS.md index bff3d44d3fa..587682bd61d 100644 --- a/tools/claude-plugin/skills/payload/reference/FIELDS.md +++ b/tools/claude-plugin/skills/payload/reference/FIELDS.md @@ -698,3 +698,47 @@ const ctaButton = link({ }, }) ``` + +## 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. From a3420122dcfe3530fd236cea9a275b0dffaf2793 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 21 Nov 2025 19:37:11 -0500 Subject: [PATCH 2/7] chore(claude): add custom endpoints to skill --- tools/claude-plugin/skills/payload/SKILL.md | 1 + .../skills/payload/reference/ADVANCED.md | 2 + .../skills/payload/reference/ENDPOINTS.md | 657 ++++++++++++++++++ 3 files changed, 660 insertions(+) create mode 100644 tools/claude-plugin/skills/payload/reference/ENDPOINTS.md diff --git a/tools/claude-plugin/skills/payload/SKILL.md b/tools/claude-plugin/skills/payload/SKILL.md index 88f3eb983fd..8bb6f0dabfb 100644 --- a/tools/claude-plugin/skills/payload/SKILL.md +++ b/tools/claude-plugin/skills/payload/SKILL.md @@ -203,6 +203,7 @@ import type { Post, User } from '@/payload-types' - **[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 diff --git a/tools/claude-plugin/skills/payload/reference/ADVANCED.md b/tools/claude-plugin/skills/payload/reference/ADVANCED.md index c2e2efe109b..c173e849923 100644 --- a/tools/claude-plugin/skills/payload/reference/ADVANCED.md +++ b/tools/claude-plugin/skills/payload/reference/ADVANCED.md @@ -156,6 +156,8 @@ Multi-step jobs that run in sequence: ## 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 diff --git a/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md b/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md new file mode 100644 index 00000000000..900a36744f4 --- /dev/null +++ b/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md @@ -0,0 +1,657 @@ +# 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` | Async function returning Web API Response | +| `root` | `boolean` | Mount on root app (bypasses `/api` prefix), top-level config only | +| `custom` | `Record` | 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` | Path parameters (e.g., `:id`) | +| `req.url` | `string` | Full request URL | +| `req.method` | `string` | HTTP method | +| `req.headers` | `Headers` | Request headers | +| `req.json()` | `() => Promise` | Parse JSON body | +| `req.text()` | `() => Promise` | 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' }) + }, + }, + ], +} +``` + +### Root Endpoints + +Mounted at root level, bypasses `/api` prefix. **Only available in top-level config.** + +```ts +import { buildConfig } from 'payload' + +export default buildConfig({ + // ... + endpoints: [ + { + path: '/health', + method: 'get', + root: true, // Available at: /health (not /api/health) + handler: async (req) => { + return Response.json({ status: 'ok' }) + }, + }, + ], +}) +``` + +## 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 From e4e3e0d3ac099a655b77bf37980fac7a7795aa2a Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 21 Nov 2025 20:13:04 -0500 Subject: [PATCH 3/7] chore: remove deprecated root prop --- .../skills/payload/reference/ENDPOINTS.md | 43 +++++-------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md b/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md index 900a36744f4..99ef908d421 100644 --- a/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md +++ b/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md @@ -6,13 +6,12 @@ Custom REST API endpoints extend Payload's auto-generated CRUD operations with c ### 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` | Async function returning Web API Response | -| `root` | `boolean` | Mount on root app (bypasses `/api` prefix), top-level config only | -| `custom` | `Record` | Extension point for plugins/metadata | +| 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` | Async function returning Web API Response | +| `custom` | `Record` | Extension point for plugins/metadata | ### Request Context @@ -521,28 +520,6 @@ export const Settings: GlobalConfig = { } ``` -### Root Endpoints - -Mounted at root level, bypasses `/api` prefix. **Only available in top-level config.** - -```ts -import { buildConfig } from 'payload' - -export default buildConfig({ - // ... - endpoints: [ - { - path: '/health', - method: 'get', - root: true, // Available at: /health (not /api/health) - handler: async (req) => { - return Response.json({ status: 'ok' }) - }, - }, - ], -}) -``` - ## Advanced Patterns ### Factory Functions @@ -651,7 +628,7 @@ export const endpoint = { ## 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 +- REST API Overview: +- Custom Endpoints: +- Access Control: +- Local API: From 7d0892d02df82503219f696a3f73ca6fe8527cae Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 21 Nov 2025 20:14:19 -0500 Subject: [PATCH 4/7] chore: remove fieldIsLocalized, deprecated --- .../skills/payload/reference/FIELD-TYPE-GUARDS.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md b/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md index 25525f75c63..59ec9380e8a 100644 --- a/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md +++ b/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md @@ -188,15 +188,8 @@ fieldShouldBeLocalized({ }): boolean ``` -**⚠️ Use Instead of `fieldIsLocalized`:** - ```ts -// ❌ DEPRECATED - doesn't account for parent localization -if (fieldIsLocalized(field)) { - /* ... */ -} - -// ✅ CORRECT - accounts for parent localization +// Accounts for parent localization if (fieldShouldBeLocalized({ field, parentIsLocalized: false })) { /* ... */ } From 4cb11909b2c5b65bd77876fe61cdbaa3494768da Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 21 Nov 2025 20:15:14 -0500 Subject: [PATCH 5/7] chore: adjust description in table --- tools/claude-plugin/skills/payload/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/claude-plugin/skills/payload/SKILL.md b/tools/claude-plugin/skills/payload/SKILL.md index 8bb6f0dabfb..00688d819e1 100644 --- a/tools/claude-plugin/skills/payload/SKILL.md +++ b/tools/claude-plugin/skills/payload/SKILL.md @@ -24,7 +24,7 @@ Payload 3.x is a Next.js native CMS with TypeScript-first architecture, providin | 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/root endpoints | [ADVANCED.md#custom-endpoints](reference/ADVANCED.md#custom-endpoints) | +| 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) | From a3f8b564a4422784f79d4e75aa9515f4c36b7ce0 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Sat, 22 Nov 2025 11:34:40 -0500 Subject: [PATCH 6/7] chore: skill optimization / audit using writing-skills --- tools/claude-plugin/skills/payload/SKILL.md | 178 +++++++++++++++++++- 1 file changed, 177 insertions(+), 1 deletion(-) diff --git a/tools/claude-plugin/skills/payload/SKILL.md b/tools/claude-plugin/skills/payload/SKILL.md index 00688d819e1..65a711ed889 100644 --- a/tools/claude-plugin/skills/payload/SKILL.md +++ b/tools/claude-plugin/skills/payload/SKILL.md @@ -1,6 +1,6 @@ --- name: payload -description: Use when working with Payload CMS projects, payload.config.ts, collections, fields, hooks, access control, or Payload API. Provides TypeScript patterns and examples for Payload 3.x development. +description: Use when working with Payload CMS projects, payload.config.ts, collections, fields, hooks, access control, or Payload API. Helpful for ValidationError, overrideAccess issues, relationship population, transaction failures, hook loops. Provides TypeScript patterns and examples for Payload 3.x development. --- # Payload CMS Application Development @@ -17,6 +17,12 @@ Payload 3.x is a Next.js native CMS with TypeScript-first architecture, providin | Draft/publish workflow | `versions: { drafts: true }` | [COLLECTIONS.md#versioning--drafts](reference/COLLECTIONS.md#versioning--drafts) | | Computed fields | `virtual: true` with afterRead | [FIELDS.md#virtual-fields](reference/FIELDS.md#virtual-fields) | | Conditional fields | `admin.condition` | [FIELDS.md#conditional-fields](reference/FIELDS.md#conditional-fields) | +| Custom field validation | `validate` function | [FIELDS.md#validation](reference/FIELDS.md#validation) | +| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) | +| Select specific fields | `select` parameter | [QUERIES.md#field-selection](reference/QUERIES.md#field-selection) | +| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) | +| Prevent hook loops | `req.context` check | [HOOKS.md#context](reference/HOOKS.md#context) | +| Cascading deletes | beforeDelete hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) | | Geospatial queries | `point` field with `near`/`within` | [FIELDS.md#point-geolocation](reference/FIELDS.md#point-geolocation) | | Reverse relationships | `join` field type | [FIELDS.md#join-fields](reference/FIELDS.md#join-fields) | | Next.js revalidation | Context control in afterChange | [HOOKS.md#nextjs-revalidation-with-context-control](reference/HOOKS.md#nextjs-revalidation-with-context-control) | @@ -139,6 +145,30 @@ export const Posts: CollectionConfig = { 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 @@ -153,10 +183,156 @@ const posts = await payload.find({ 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
{docs.map(post =>

{post.title}

)}
+} +``` + +## 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 From a973aeb2fd322bcb835c32d4c84fc19457aa722a Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Sat, 22 Nov 2025 11:41:27 -0500 Subject: [PATCH 7/7] chore: update skill description, remove 3.x mentions --- tools/claude-plugin/README.md | 2 +- tools/claude-plugin/skills/payload/SKILL.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/claude-plugin/README.md b/tools/claude-plugin/README.md index f2f7d03b0b7..11a6e109288 100644 --- a/tools/claude-plugin/README.md +++ b/tools/claude-plugin/README.md @@ -1,6 +1,6 @@ # Payload Skill for Claude Code -Claude Code skill providing comprehensive guidance for Payload 3.x development with TypeScript patterns, field configurations, hooks, access control, and API examples. +Claude Code skill providing comprehensive guidance for Payload development with TypeScript patterns, field configurations, hooks, access control, and API examples. ## Installation diff --git a/tools/claude-plugin/skills/payload/SKILL.md b/tools/claude-plugin/skills/payload/SKILL.md index 65a711ed889..67e81af1066 100644 --- a/tools/claude-plugin/skills/payload/SKILL.md +++ b/tools/claude-plugin/skills/payload/SKILL.md @@ -1,11 +1,11 @@ --- name: payload -description: Use when working with Payload CMS projects, payload.config.ts, collections, fields, hooks, access control, or Payload API. Helpful for ValidationError, overrideAccess issues, relationship population, transaction failures, hook loops. Provides TypeScript patterns and examples for Payload 3.x development. +description: Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior. --- # Payload CMS Application Development -Payload 3.x is a Next.js native CMS with TypeScript-first architecture, providing admin panel, database management, REST/GraphQL APIs, authentication, and file storage. +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