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 55b4b5b0144..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. 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
@@ -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) |
@@ -24,13 +30,14 @@ 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) |
| 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
@@ -138,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
@@ -152,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
@@ -196,11 +373,13 @@ 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
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..99ef908d421
--- /dev/null
+++ b/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md
@@ -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` | Async function returning Web API Response |
+| `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' })
+ },
+ },
+ ],
+}
+```
+
+## 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:
+- Custom Endpoints:
+- Access Control:
+- Local API:
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..59ec9380e8a
--- /dev/null
+++ b/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md
@@ -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(
+ 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
+```
+
+```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(
+ 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.