Claude Skill

api-cms-payload

Payload CMS v3 — TypeScript-native headless CMS with code-first collections, hooks, access control, Local/REST/GraphQL APIs, admin panel, and database adapter pattern

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_api-cms-payload_skills_api-cms-payload-3a51ef5.zip · 17 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-cms-payload/skills/api-cms-payload
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git git clone https://github.com/agents-inc/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Payload CMS Patterns

Quick Guide: Use Payload for code-first content management with TypeScript. Define collections and globals as config objects with typed fields, hooks, and access control functions. Prefer the Local API (payload.find, payload.create) for server-side operations. Always generate TypeScript types from your config. Use database adapters (Postgres or MongoDB) and never hardcode credentials. Access control functions receive { req } with the authenticated user. Hooks run at the document lifecycle level (beforeChange, afterChange, etc.) and must not have side effects that block the request unless intentional.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST define access control on every collection — open collections are a security risk)

(You MUST use the Local API (payload.find, payload.create) for server-side data operations — it is zero-latency and fully typed)

(You MUST generate TypeScript types with payload generate:types after every schema change)

(You MUST keep JSX/React component imports OUT of the Payload config file — separate config and UI concerns)

(You MUST use overrideAccess: false when calling the Local API on behalf of a user — the default is true which bypasses all access control)

</critical_requirements>


Auto-detection: Payload, payload, payloadcms, @payloadcms, buildConfig, CollectionConfig, GlobalConfig, payload.config.ts, payload.find, payload.create, payload.update, payload.delete, payload.findByID, lexicalEditor, richText, beforeChange, afterChange, afterRead, beforeValidate, access control payload, upload collection, imageSizes, versions drafts

When to use:

  • Configuring payload.config.ts with database adapter, collections, and globals
  • Defining collection schemas with typed fields (text, richText, relationship, blocks, array, group, upload, select)
  • Implementing access control functions (role-based, ownership-based, field-level)
  • Writing collection hooks (beforeChange, afterChange, beforeRead, afterRead, beforeValidate, beforeDelete, afterDelete)
  • Querying data via Local API, REST API, or GraphQL
  • Setting up authentication collections with login, roles, and JWT
  • Configuring uploads/media with image sizes and mime type restrictions
  • Enabling versions and drafts on collections or globals
  • Customizing the admin panel (groups, hidden collections, custom components)

Key patterns covered:

  • payload.config.ts setup with buildConfig, database adapters, editor config
  • Collection config: slug, fields, hooks, access, auth, upload, versions, admin
  • Field types: text, richText, relationship, upload, blocks, array, group, select, tabs, checkbox, date, number, email, code, json, point, radio, textarea, row, collapsible
  • Access control: collection-level and field-level, returning boolean or Where query
  • Hooks: beforeChange, afterChange, beforeRead, afterRead, beforeValidate, beforeDelete, afterDelete, beforeOperation, afterOperation
  • Local API: payload.find, payload.findByID, payload.create, payload.update, payload.delete, payload.count
  • REST API: auto-generated endpoints at /api/{collection-slug}
  • Globals: singleton documents for site settings, navigation, footer
  • Auth collections: auth: true, roles, login strategies
  • Uploads: imageSizes, mimeTypes, media collections
  • Versions and drafts: versions: { drafts: true }
  • TypeScript type generation

When NOT to use:

  • Simple key-value storage (use a database directly)
  • Static site generation without content editing needs
  • Applications that only need a REST API without an admin panel (use a plain API framework)
  • Client-side data fetching patterns (Payload's Local API is server-only)

Detailed Resources:

  • For decision frameworks and anti-patterns, see reference.md

Core Setup & Collections:

  • examples/core.md — Config setup, collection definitions, field types, access control, hooks

Advanced Patterns:

  • examples/advanced.md — Globals, versions/drafts, uploads/media, auth collections, Local API, REST API



<decision_framework>

Decision Framework

Which API to Use

Where is the code running?
+-- Server-side (API route, server component, script)
|   +-- Local API (zero-latency, fully typed, preferred)
+-- External client (browser, mobile app, third-party)
|   +-- REST API (/api/{collection-slug})
+-- GraphQL client
    +-- GraphQL API (/api/graphql)

Field Type Selection

What kind of data?
+-- Single value
|   +-- Short text --> text
|   +-- Long text --> textarea
|   +-- Rich content --> richText
|   +-- Number --> number
|   +-- Boolean --> checkbox
|   +-- Date/time --> date
|   +-- Email --> email
|   +-- Coordinates --> point
|   +-- Code snippet --> code
|   +-- Arbitrary JSON --> json
+-- Choice from options
|   +-- Single choice (dropdown) --> select
|   +-- Single choice (visible) --> radio
|   +-- Linked document --> relationship
|   +-- File/image --> upload
+-- Nested structure
|   +-- Fixed group of fields --> group
|   +-- Repeatable rows (same shape) --> array
|   +-- Flexible content (multiple block types) --> blocks
+-- Admin layout only (no data effect)
    +-- Tabbed sections --> tabs
    +-- Side-by-side fields --> row
    +-- Collapsible section --> collapsible

Access Control Strategy

Who should access this data?
+-- Public (anyone) --> read: () => true
+-- Authenticated users only --> read: ({ req: { user } }) => Boolean(user)
+-- Admin only --> read: ({ req: { user } }) => user?.role === 'admin'
+-- Owner only --> read: return Where query matching user.id
+-- Mixed (public read, auth write) --> Different function per operation
+-- Field-level restriction --> access on individual field config

Hooks vs Access Control

What do you need to do?
+-- Control WHO can do something --> Access control
+-- Control WHAT happens when they do it --> Hooks
+-- Validate data before saving --> beforeValidate hook or field validation
+-- Transform data before saving --> beforeChange hook
+-- Trigger side effects after saving --> afterChange hook
+-- Filter/transform output --> afterRead hook

</decision_framework>


<red_flags>

RED FLAGS

High Priority Issues:

  • Missing access control on collections -- Without explicit access functions, Payload denies all access to unauthenticated users but grants full access to any authenticated user. Always define explicit access rules.
  • overrideAccess default is true in Local API -- Every payload.find(), payload.create(), etc. call bypasses access control by default. Always pass overrideAccess: false when operating on behalf of a user.
  • Importing JSX/React components in payload.config.ts -- Payload config runs in a Node context. Importing React components (even transitively) causes bundling errors. Keep config and UI imports completely separate.
  • Hardcoded secret or database URL -- Use environment variables. The Payload secret is used to sign JWTs; hardcoding it is a security vulnerability.

Medium Priority Issues:

  • Using select("*") equivalent -- In the Local API, not specifying select returns all fields. Use the select option to fetch only needed fields for performance.
  • Deep depth values -- Default depth is 2. High depth values cause cascading relationship queries. Set depth: 0 or depth: 1 unless you need deeply nested relationships.
  • Blocking hooks with external calls -- beforeChange and beforeValidate hooks block the save operation. Move non-critical external API calls to afterChange or use background processing.
  • Not running payload generate:types after schema changes -- Stale types lead to runtime errors that TypeScript should have caught at compile time.

Common Mistakes:

  • Deep-cloning collection configs -- JSON.parse(JSON.stringify(config)) strips hooks and access functions (they are functions, not serializable data). Use spread or Object.assign instead.
  • Forgetting .select() equivalent after create/update -- In the Local API, payload.create and payload.update return the full document by default. Use the select option if you need specific fields.
  • Using FOR ALL style access -- Define separate access functions for create, read, update, delete instead of a single function. Different operations have different security requirements.
  • Monorepo version mismatches -- All packages in a monorepo must use the same version of payload, @payloadcms/*, next, react, and react-dom. Mismatches cause subtle bundling errors.

Gotchas & Edge Cases:

  • beforeChange data is a partial on update -- On update operations, data contains only the changed fields, not the full document. Use originalDoc to access existing values.
  • beforeChange has no id on create -- The document ID is not available during beforeChange on create operations. If you need the ID, use afterChange.
  • overrideAccess defaults -- Local API defaults to true (bypass access control). REST and GraphQL always enforce access control. This asymmetry is intentional but catches people off guard.
  • Tabs, rows, and collapsibles do not affect data shape -- These are admin-only layout fields. A field inside a tab is stored at the top level of the document, not nested.
  • Relationship depth cascading -- Setting depth: 3 on a collection with circular relationships can cause exponential query growth. Keep depth as low as possible.
  • Auth collections auto-inject fields -- Collections with auth: true automatically get email, hash, salt, loginAttempts, and lockUntil fields. Do not redefine them.
  • Versions create a separate table -- Enabling versions: true creates a _posts_versions table (or equivalent). This can significantly increase storage for high-traffic collections.
  • Access control Where queries run as SQL -- When an access function returns a Where query instead of a boolean, it is appended to the database query. Complex Where queries can impact database performance.

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST define access control on every collection — open collections are a security risk)

(You MUST use the Local API (payload.find, payload.create) for server-side data operations — it is zero-latency and fully typed)

(You MUST generate TypeScript types with payload generate:types after every schema change)

(You MUST keep JSX/React component imports OUT of the Payload config file — separate config and UI concerns)

(You MUST use overrideAccess: false when calling the Local API on behalf of a user — the default is true which bypasses all access control)

Failure to follow these rules will create security vulnerabilities, type-unsafe operations, and bundling errors.

</critical_reminders>

Files (skills)
  • examples
    • advanced.md 16.1 KB
      # Payload CMS - Advanced Examples
      
      > Globals, versions/drafts, uploads/media, auth collections, Local API, and REST API. See [core.md](core.md) for foundational patterns.
      
      **Prerequisites**: Understand collection config, fields, access control, and hooks from core examples first.
      
      ---
      
      ## Pattern 7: Globals — Singleton Documents
      
      Globals are single documents (not collections) used for site-wide settings like navigation, footer, or SEO defaults.
      
      ### Good Example — Site Settings Global
      
      ```typescript
      // globals/site-settings.ts
      import type { GlobalConfig } from "payload";
      import { isAdmin } from "../access";
      
      const SiteSettings: GlobalConfig = {
        slug: "site-settings",
        access: {
          read: () => true,
          update: isAdmin,
        },
        fields: [
          {
            name: "siteName",
            type: "text",
            required: true,
          },
          {
            name: "siteDescription",
            type: "textarea",
          },
          {
            name: "logo",
            type: "upload",
            relationTo: "media",
          },
          {
            name: "socialLinks",
            type: "array",
            fields: [
              {
                name: "platform",
                type: "select",
                options: [
                  { label: "Twitter", value: "twitter" },
                  { label: "GitHub", value: "github" },
                  { label: "LinkedIn", value: "linkedin" },
                ],
              },
              { name: "url", type: "text", required: true },
            ],
          },
        ],
      };
      
      export { SiteSettings };
      ```
      
      ### Good Example — Navigation Global
      
      ```typescript
      // globals/navigation.ts
      import type { GlobalConfig } from "payload";
      
      const Navigation: GlobalConfig = {
        slug: "navigation",
        access: {
          read: () => true,
          update: ({ req: { user } }) => Boolean(user),
        },
        fields: [
          {
            name: "items",
            type: "array",
            fields: [
              { name: "label", type: "text", required: true },
              { name: "url", type: "text", required: true },
              {
                name: "children",
                type: "array",
                fields: [
                  { name: "label", type: "text", required: true },
                  { name: "url", type: "text", required: true },
                ],
              },
            ],
          },
        ],
      };
      
      export { Navigation };
      ```
      
      **Why good:** Globals for site-wide singleton data, separate access for read vs update, nested arrays for navigation hierarchy, public read with authenticated update
      
      ### Reading Globals via Local API
      
      ```typescript
      import { getPayload } from "payload";
      import config from "@payload-config";
      
      async function getSiteSettings() {
        const payload = await getPayload({ config });
      
        const settings = await payload.findGlobal({
          slug: "site-settings",
          depth: 1,
        });
      
        return settings;
      }
      
      async function updateSiteSettings(data: { siteName: string }) {
        const payload = await getPayload({ config });
      
        const settings = await payload.updateGlobal({
          slug: "site-settings",
          data,
          overrideAccess: false,
        });
      
        return settings;
      }
      ```
      
      ---
      
      ## Pattern 8: Versions and Drafts
      
      Enable versioning to track document history and support draft/publish workflows.
      
      ### Good Example — Collection with Drafts
      
      ```typescript
      // collections/pages.ts
      import type { CollectionConfig } from "payload";
      
      const Pages: CollectionConfig = {
        slug: "pages",
        admin: {
          useAsTitle: "title",
        },
        versions: {
          drafts: {
            autosave: true, // Auto-save drafts in the admin panel
            schedulePublish: true, // Enable scheduled publishing
            validate: false, // Skip validation for drafts (allow incomplete content)
          },
          maxPerDoc: 10, // Keep last 10 versions per document
        },
        access: {
          read: ({ req: { user } }) => {
            if (user) return true;
            // Public users only see published versions
            return {
              _status: {
                equals: "published",
              },
            };
          },
          update: ({ req: { user } }) => Boolean(user),
        },
        fields: [
          { name: "title", type: "text", required: true },
          { name: "content", type: "richText" },
          // _status field is auto-added when drafts: true
          // Values: "draft" | "published"
        ],
      };
      
      export { Pages };
      ```
      
      **Why good:** `maxPerDoc` prevents unbounded version growth, `autosave` for real-time draft saving in admin, `schedulePublish` enables future publishing, `validate: false` allows saving incomplete drafts, access control returns `Where` query to filter drafts from public users, `_status` field is auto-added by Payload when drafts are enabled
      
      ### Publishing via Local API
      
      ```typescript
      import { getPayload } from "payload";
      import config from "@payload-config";
      
      // Create as draft (default when drafts enabled)
      async function createDraftPage(title: string, content: object) {
        const payload = await getPayload({ config });
      
        return payload.create({
          collection: "pages",
          data: { title, content },
          draft: true, // Explicitly save as draft
          overrideAccess: false,
        });
      }
      
      // Publish a draft
      async function publishPage(id: string) {
        const payload = await getPayload({ config });
      
        return payload.update({
          collection: "pages",
          id,
          data: {
            _status: "published",
          },
          overrideAccess: false,
        });
      }
      
      // Restore a previous version
      async function restoreVersion(versionId: string) {
        const payload = await getPayload({ config });
      
        return payload.restoreVersion({
          collection: "pages",
          id: versionId,
          overrideAccess: false,
        });
      }
      ```
      
      **Why good:** `draft: true` creates without publishing, publishing is just updating `_status`, version restore via dedicated API method, all with `overrideAccess: false`
      
      ---
      
      ## Pattern 9: Upload / Media Collection
      
      Upload collections handle file storage with automatic image resizing.
      
      ### Good Example — Media Collection with Image Sizes
      
      ```typescript
      // collections/media.ts
      import type { CollectionConfig } from "payload";
      
      const THUMBNAIL_WIDTH = 400;
      const CARD_WIDTH = 768;
      const DESKTOP_WIDTH = 1920;
      
      const Media: CollectionConfig = {
        slug: "media",
        admin: {
          useAsTitle: "alt",
          group: "Media",
        },
        access: {
          read: () => true,
          create: ({ req: { user } }) => Boolean(user),
          update: ({ req: { user } }) => Boolean(user),
          delete: ({ req: { user } }) => user?.role === "admin",
        },
        upload: {
          mimeTypes: ["image/*", "application/pdf"],
          imageSizes: [
            {
              name: "thumbnail",
              width: THUMBNAIL_WIDTH,
              height: THUMBNAIL_WIDTH,
              position: "centre",
            },
            {
              name: "card",
              width: CARD_WIDTH,
              height: undefined, // Retains aspect ratio
              position: "centre",
            },
            {
              name: "desktop",
              width: DESKTOP_WIDTH,
              height: undefined,
              position: "centre",
            },
          ],
        },
        fields: [
          {
            name: "alt",
            type: "text",
            required: true,
          },
          {
            name: "caption",
            type: "textarea",
          },
        ],
      };
      
      export { Media };
      ```
      
      **Why good:** Named constants for image dimensions, `mimeTypes` restricts uploads to images and PDFs, `height: undefined` preserves aspect ratio, `alt` text required for accessibility, `useAsTitle` set to alt for admin display, separate access for delete (admin only)
      
      ### Bad Example — No Restrictions
      
      ```typescript
      // BAD: No access control, no mime type restrictions
      const Media: CollectionConfig = {
        slug: "media",
        upload: true, // Default config — accepts ANY file type
        fields: [],
      };
      ```
      
      **Why bad:** No access control (any authenticated user can delete files), no mime type restriction (users can upload executables), no alt text field, no image size variants
      
      ---
      
      ## Pattern 10: Authentication Collection
      
      Collections with `auth: true` automatically get email, password, login, and JWT functionality.
      
      ### Good Example — Users with Roles
      
      ```typescript
      // collections/users.ts
      import type { CollectionConfig } from "payload";
      import { isAdmin } from "../access";
      
      const Users: CollectionConfig = {
        slug: "users",
        auth: true, // Adds email, hash, salt, login, JWT
        admin: {
          useAsTitle: "email",
          group: "Admin",
        },
        access: {
          read: ({ req: { user } }) => {
            if (!user) return false;
            if (user.role === "admin") return true;
            // Non-admins can only read their own profile
            return { id: { equals: user.id } };
          },
          create: isAdmin,
          update: ({ req: { user } }) => {
            if (!user) return false;
            if (user.role === "admin") return true;
            return { id: { equals: user.id } };
          },
          delete: isAdmin,
          admin: ({ req: { user } }) => user?.role === "admin",
        },
        fields: [
          {
            name: "role",
            type: "select",
            required: true,
            defaultValue: "editor",
            options: [
              { label: "Admin", value: "admin" },
              { label: "Editor", value: "editor" },
              { label: "Author", value: "author" },
            ],
            access: {
              update: ({ req: { user } }) => user?.role === "admin",
            },
          },
          {
            name: "firstName",
            type: "text",
          },
          {
            name: "lastName",
            type: "text",
          },
        ],
      };
      
      export { Users };
      ```
      
      **Why good:** `auth: true` handles email/password/JWT automatically, role field with field-level access (only admins can change roles), `admin` access function controls who sees the admin panel, scoped read/update for non-admins (own profile only)
      
      ### Auth with Login by Username
      
      ```typescript
      const Users: CollectionConfig = {
        slug: "users",
        auth: {
          loginWithUsername: {
            allowEmailLogin: true, // Users can log in with email OR username
            requireUsername: true,
          },
          tokenExpiration: 7200, // 2 hours in seconds
        },
        fields: [{ name: "role", type: "select", options: ["admin", "editor"] }],
      };
      ```
      
      **Why good:** Allows username-based login while keeping email as fallback, explicit token expiration
      
      ---
      
      ## Pattern 11: Local API — Complete Operations
      
      ### Good Example — CRUD with Access Control
      
      ```typescript
      import { getPayload } from "payload";
      import config from "@payload-config";
      
      const PAGE_SIZE = 20;
      
      // Find with filters, pagination, and sorting
      async function getPublishedPosts(page: number) {
        const payload = await getPayload({ config });
      
        return payload.find({
          collection: "posts",
          where: {
            status: { equals: "published" },
          },
          sort: "-createdAt",
          page,
          limit: PAGE_SIZE,
          depth: 1,
          overrideAccess: false,
        });
        // Returns: { docs: Post[], totalDocs, totalPages, page, ... }
      }
      
      // Find by ID
      async function getPostById(id: string) {
        const payload = await getPayload({ config });
      
        return payload.findByID({
          collection: "posts",
          id,
          depth: 1,
          overrideAccess: false,
        });
      }
      
      // Create
      async function createPost(data: {
        title: string;
        content: object;
        author: string;
      }) {
        const payload = await getPayload({ config });
      
        return payload.create({
          collection: "posts",
          data,
          overrideAccess: false,
        });
      }
      
      // Update by ID
      async function updatePost(
        id: string,
        data: Partial<{ title: string; status: string }>,
      ) {
        const payload = await getPayload({ config });
      
        return payload.update({
          collection: "posts",
          id,
          data,
          overrideAccess: false,
        });
      }
      
      // Bulk update with Where query
      async function publishAllDrafts() {
        const payload = await getPayload({ config });
      
        return payload.update({
          collection: "posts",
          where: {
            status: { equals: "draft" },
          },
          data: {
            status: "published",
          },
          overrideAccess: false,
        });
      }
      
      // Delete by ID
      async function deletePost(id: string) {
        const payload = await getPayload({ config });
      
        return payload.delete({
          collection: "posts",
          id,
          overrideAccess: false,
        });
      }
      
      // Count documents
      async function countPublishedPosts() {
        const payload = await getPayload({ config });
      
        const result = await payload.count({
          collection: "posts",
          where: {
            status: { equals: "published" },
          },
        });
      
        return result.totalDocs;
      }
      ```
      
      **Why good:** Every call uses `overrideAccess: false` to enforce access control, pagination with named constant, `depth: 1` to control relationship population, bulk update via `where` query, count for efficient totals without loading documents
      
      ---
      
      ## Pattern 12: REST API — Auto-Generated Endpoints
      
      Payload auto-generates REST endpoints at `/api/{collection-slug}`. These are useful for external clients.
      
      ### Endpoint Reference
      
      ```
      GET    /api/posts                    — Find (paginated)
      GET    /api/posts/:id                — Find by ID
      POST   /api/posts                    — Create
      PATCH  /api/posts/:id                — Update by ID
      DELETE /api/posts/:id                — Delete by ID
      GET    /api/posts/count              — Count
      GET    /api/globals/site-settings    — Read global
      POST   /api/globals/site-settings    — Update global
      POST   /api/users/login              — Login (auth collections)
      POST   /api/users/logout             — Logout
      GET    /api/users/me                 — Current user
      POST   /api/users/forgot-password    — Forgot password
      POST   /api/users/reset-password     — Reset password
      ```
      
      ### Query Parameters
      
      ```
      ?where[status][equals]=published     — Filter
      ?sort=-createdAt                     — Sort descending
      ?limit=20                            — Pagination limit
      ?page=2                              — Pagination page
      ?depth=1                             — Relationship depth
      ?locale=en                           — Locale
      ?select[title]=true&select[slug]=true — Select specific fields
      ```
      
      ### Example — Fetch Published Posts
      
      ```typescript
      const API_URL = process.env.API_URL;
      const PAGE_SIZE = 20;
      
      async function fetchPublishedPosts(page: number) {
        const params = new URLSearchParams({
          "where[status][equals]": "published",
          sort: "-createdAt",
          limit: String(PAGE_SIZE),
          page: String(page),
          depth: "1",
        });
      
        const response = await fetch(`${API_URL}/api/posts?${params}`);
      
        if (!response.ok) {
          throw new Error(`API error: ${response.status}`);
        }
      
        return response.json();
      }
      ```
      
      **Why good:** REST API is fully auto-generated from collection config, query parameters mirror Local API options, no separate route definitions needed
      
      ---
      
      ## Pattern 13: Hooks — Using payload Inside Hooks
      
      Hooks receive `req.payload` which gives access to the Local API. This is the correct way to perform cross-collection operations within hooks.
      
      ### Good Example — Create Related Document in afterChange
      
      ```typescript
      // hooks/create-audit-log.ts
      import type { CollectionAfterChangeHook } from "payload";
      
      const createAuditLog: CollectionAfterChangeHook = async ({
        doc,
        operation,
        req,
        context,
      }) => {
        // Use context to prevent infinite loops (audit log creation triggering itself)
        if (context.skipAuditLog) return;
      
        // Use req.payload to access the Local API within hooks
        await req.payload.create({
          collection: "audit-logs",
          data: {
            action: operation,
            documentId: doc.id,
            collection: "posts",
            user: req.user?.id,
            timestamp: new Date().toISOString(),
          },
          // overrideAccess: true (default) — hooks run in system context
        });
      };
      
      export { createAuditLog };
      ```
      
      **Why good:** `req.payload` provides the Local API within hooks, `context` check prevents infinite loops when audit log creation might trigger other hooks, `overrideAccess: true` (default) is correct here because this is a system-level operation, runs after the primary operation so it does not block saves
      
      ### Good Example — Validate with Cross-Collection Lookup
      
      ```typescript
      // hooks/validate-unique-slug.ts
      import type { CollectionBeforeValidateHook } from "payload";
      
      const validateUniqueSlug: CollectionBeforeValidateHook = async ({
        data,
        operation,
        req,
        originalDoc,
      }) => {
        if (!data?.slug) return data;
      
        const existing = await req.payload.find({
          collection: "posts",
          where: {
            slug: { equals: data.slug },
            ...(operation === "update" && originalDoc?.id
              ? { id: { not_equals: originalDoc.id } }
              : {}),
          },
          limit: 1,
          req, // Pass req for transaction threading
        });
      
        if (existing.docs.length > 0) {
          throw new Error(`Slug "${data.slug}" is already in use`);
        }
      
        return data;
      };
      
      export { validateUniqueSlug };
      ```
      
      **Why good:** Cross-collection lookup to validate uniqueness, excludes current document on update, throws to prevent save with clear error message, `limit: 1` for efficient check
      
      ---
      
      _For core patterns (config, collections, fields, access), see [core.md](core.md)._
      
    • core.md 13 KB
      # Payload CMS Core Examples
      
      > Config setup, collection definitions, field types, access control, and hooks. See [SKILL.md](../SKILL.md) for core concepts.
      
      **Advanced patterns:** See [advanced.md](advanced.md) for globals, versions/drafts, uploads, auth, and API usage.
      
      ---
      
      ## Pattern 1: payload.config.ts — Postgres Adapter
      
      ### Good Example — Production Config
      
      ```typescript
      // payload.config.ts
      import { buildConfig } from "payload";
      import { postgresAdapter } from "@payloadcms/db-postgres";
      import { lexicalEditor } from "@payloadcms/richtext-lexical";
      import { Pages } from "./collections/pages";
      import { Posts } from "./collections/posts";
      import { Users } from "./collections/users";
      import { Media } from "./collections/media";
      import { SiteSettings } from "./globals/site-settings";
      import { Navigation } from "./globals/navigation";
      
      const config = buildConfig({
        db: postgresAdapter({
          pool: {
            connectionString: process.env.DATABASE_URL,
          },
        }),
        editor: lexicalEditor(),
        collections: [Pages, Posts, Users, Media],
        globals: [SiteSettings, Navigation],
        admin: {
          user: Users.slug,
          meta: {
            titleSuffix: " — My CMS",
          },
        },
        typescript: {
          outputFile: "./src/payload-types.ts",
        },
        secret: process.env.PAYLOAD_SECRET!,
      });
      
      export { config as default };
      ```
      
      **Why good:** Database URL from env var, collections in separate files, editor declared once, TypeScript output path specified, admin user collection set, secret from env var
      
      ### Good Example — MongoDB Adapter
      
      ```typescript
      // payload.config.ts
      import { buildConfig } from "payload";
      import { mongooseAdapter } from "@payloadcms/db-mongodb";
      import { lexicalEditor } from "@payloadcms/richtext-lexical";
      import { Posts } from "./collections/posts";
      import { Users } from "./collections/users";
      
      const config = buildConfig({
        db: mongooseAdapter({
          url: process.env.DATABASE_URI!,
        }),
        editor: lexicalEditor(),
        collections: [Posts, Users],
        admin: {
          user: Users.slug,
        },
        secret: process.env.PAYLOAD_SECRET!,
      });
      
      export { config as default };
      ```
      
      **Why good:** Same config structure regardless of database, only the adapter import and options change
      
      ### Bad Example — Hardcoded Credentials
      
      ```typescript
      // BAD: Hardcoded values, inline collections
      import { buildConfig } from "payload";
      import { postgresAdapter } from "@payloadcms/db-postgres";
      
      export default buildConfig({
        db: postgresAdapter({
          pool: { connectionString: "postgres://user:pass@localhost/mydb" }, // BAD
        }),
        secret: "not-a-real-secret", // BAD: hardcoded
        collections: [
          { slug: "posts", fields: [{ name: "title", type: "text" }] }, // Inline
        ],
      });
      ```
      
      **Why bad:** Hardcoded credentials leak in source control, inline collections become unmaintainable, no TypeScript output, no admin user
      
      ---
      
      ## Pattern 2: Collection with Full Config
      
      ### Good Example — Blog Posts Collection
      
      ```typescript
      // collections/posts.ts
      import type { CollectionConfig } from "payload";
      import { isAdmin, isAdminOrAuthor } from "../access";
      import { setAuthorOnCreate } from "../hooks/set-author";
      import { revalidatePostCache } from "../hooks/revalidate-cache";
      
      const Posts: CollectionConfig = {
        slug: "posts",
        admin: {
          useAsTitle: "title",
          defaultColumns: ["title", "status", "author", "createdAt"],
          group: "Content",
        },
        access: {
          read: () => true,
          create: ({ req: { user } }) => Boolean(user),
          update: isAdminOrAuthor,
          delete: isAdmin,
        },
        hooks: {
          beforeChange: [setAuthorOnCreate],
          afterChange: [revalidatePostCache],
        },
        versions: {
          drafts: true,
        },
        fields: [
          {
            name: "title",
            type: "text",
            required: true,
          },
          {
            name: "slug",
            type: "text",
            required: true,
            unique: true,
            admin: {
              position: "sidebar",
            },
          },
          {
            name: "status",
            type: "select",
            defaultValue: "draft",
            options: [
              { label: "Draft", value: "draft" },
              { label: "Published", value: "published" },
            ],
            admin: {
              position: "sidebar",
            },
          },
          {
            name: "content",
            type: "richText",
          },
          {
            name: "excerpt",
            type: "textarea",
            maxLength: 300,
          },
          {
            name: "featuredImage",
            type: "upload",
            relationTo: "media",
          },
          {
            name: "author",
            type: "relationship",
            relationTo: "users",
            required: true,
            admin: {
              position: "sidebar",
            },
          },
          {
            name: "tags",
            type: "array",
            fields: [
              {
                name: "tag",
                type: "text",
                required: true,
              },
            ],
          },
          {
            type: "tabs",
            tabs: [
              {
                label: "SEO",
                fields: [
                  { name: "metaTitle", type: "text" },
                  { name: "metaDescription", type: "textarea" },
                ],
              },
            ],
          },
        ],
      };
      
      export { Posts };
      ```
      
      **Why good:** Hooks and access imported from separate files, `useAsTitle` for admin list display, `admin.position: "sidebar"` for fields that belong in the sidebar, versions with drafts enabled, `group: "Content"` organizes admin navigation, SEO fields in a tab for clean admin UI
      
      ---
      
      ## Pattern 3: Access Control Patterns
      
      ### Good Example — Reusable Access Functions
      
      ```typescript
      // access/index.ts
      import type { Access, FieldAccess } from "payload";
      
      // Anyone
      const isPublic: Access = () => true;
      
      // Any authenticated user
      const isLoggedIn: Access = ({ req: { user } }) => Boolean(user);
      
      // Admin role only
      const isAdmin: Access = ({ req: { user } }) => {
        if (!user) return false;
        return user.role === "admin";
      };
      
      // Admin or document author (returns Where query for scoped access)
      const isAdminOrAuthor: Access = ({ req: { user } }) => {
        if (!user) return false;
        if (user.role === "admin") return true;
      
        return {
          author: {
            equals: user.id,
          },
        };
      };
      
      // Field-level: admin only
      const adminFieldAccess: FieldAccess = ({ req: { user } }) => {
        return user?.role === "admin";
      };
      
      export { isPublic, isLoggedIn, isAdmin, isAdminOrAuthor, adminFieldAccess };
      ```
      
      **Why good:** Reusable across collections, `isAdminOrAuthor` returns a `Where` query to scope results (users only see their own documents, admins see all), field-level access uses `FieldAccess` type, clear naming
      
      ### Bad Example — Inline Unscoped Access
      
      ```typescript
      // BAD: No access control defined
      const Posts: CollectionConfig = {
        slug: "posts",
        // access: not specified — authenticated users get FULL access
        fields: [{ name: "title", type: "text" }],
      };
      
      // BAD: Overly permissive
      const Posts: CollectionConfig = {
        slug: "posts",
        access: {
          read: () => true,
          create: () => true, // Anyone can create, including unauthenticated!
          update: () => true, // Anyone can update anything!
          delete: () => true, // Anyone can delete anything!
        },
        fields: [{ name: "title", type: "text" }],
      };
      ```
      
      **Why bad:** First example relies on defaults (authenticated users get full access), second example gives full write access to unauthenticated users, no ownership scoping, no role checks
      
      ---
      
      ## Pattern 4: Hooks — beforeChange and afterChange
      
      ### Good Example — Auto-Set Author and Revalidate Cache
      
      ```typescript
      // hooks/set-author.ts
      import type { CollectionBeforeChangeHook } from "payload";
      
      const setAuthorOnCreate: CollectionBeforeChangeHook = ({
        data,
        operation,
        req,
      }) => {
        if (operation === "create" && req.user) {
          data.author = req.user.id;
        }
        return data;
      };
      
      export { setAuthorOnCreate };
      ```
      
      ```typescript
      // hooks/revalidate-cache.ts
      import type { CollectionAfterChangeHook } from "payload";
      
      const revalidatePostCache: CollectionAfterChangeHook = ({
        doc,
        operation,
        req,
        context,
      }) => {
        // Use context to prevent infinite loops when hooks trigger other operations
        if (context.skipRevalidation) return;
      
        if (operation === "create" || operation === "update") {
          // Non-blocking side effect — does not block the response
          req.payload.logger.info(`Cache revalidation triggered for post: ${doc.id}`);
          // Use your cache invalidation strategy here
        }
      };
      
      export { revalidatePostCache };
      ```
      
      **Why good:** `beforeChange` returns modified `data` to pass changes forward, `operation` check prevents overwriting author on updates, `afterChange` for non-blocking side effects, `context` prevents infinite loops when hooks trigger each other, each hook in a separate file for testability
      
      ### Good Example — beforeValidate for Computed Fields
      
      ```typescript
      // hooks/generate-slug.ts
      import type { CollectionBeforeValidateHook } from "payload";
      
      const generateSlug: CollectionBeforeValidateHook = ({ data, operation }) => {
        if (data?.title && (operation === "create" || !data.slug)) {
          data.slug = data.title
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, "-")
            .replace(/(^-|-$)/g, "");
        }
        return data;
      };
      
      export { generateSlug };
      ```
      
      **Why good:** Generates slug from title on create or when slug is empty, uses `beforeValidate` so the generated slug passes validation, idempotent logic
      
      ### Bad Example — Blocking External Call in beforeChange
      
      ```typescript
      // BAD: External API in beforeChange
      const syncToExternalSystem: CollectionBeforeChangeHook = async ({ data }) => {
        // If external API is slow or down, EVERY save operation is blocked
        const response = await fetch("https://external-api.com/sync", {
          method: "POST",
          body: JSON.stringify(data),
        });
      
        if (!response.ok) {
          throw new Error("External sync failed"); // Prevents save!
        }
      
        return data;
      };
      ```
      
      **Why bad:** Blocks every save operation on external API availability, network failures prevent all document saves, should use `afterChange` for non-critical syncs or queue for reliability
      
      ---
      
      ## Pattern 5: Field Types — Blocks for Flexible Layouts
      
      ### Good Example — Page Builder with Blocks
      
      ```typescript
      // fields/layout-blocks.ts
      import type { Block } from "payload";
      
      const HeroBlock: Block = {
        slug: "hero",
        interfaceName: "HeroBlock", // Custom TypeScript interface name for generated types
        fields: [
          { name: "heading", type: "text", required: true },
          { name: "subheading", type: "textarea" },
          { name: "backgroundImage", type: "upload", relationTo: "media" },
          {
            name: "cta",
            type: "group",
            fields: [
              { name: "label", type: "text", required: true },
              { name: "url", type: "text", required: true },
            ],
          },
        ],
      };
      
      const ContentBlock: Block = {
        slug: "content",
        fields: [{ name: "richText", type: "richText" }],
      };
      
      const CallToActionBlock: Block = {
        slug: "cta",
        fields: [
          { name: "heading", type: "text", required: true },
          { name: "description", type: "textarea" },
          {
            name: "buttons",
            type: "array",
            minRows: 1,
            maxRows: 3,
            fields: [
              { name: "label", type: "text", required: true },
              { name: "url", type: "text", required: true },
              {
                name: "variant",
                type: "select",
                defaultValue: "primary",
                options: [
                  { label: "Primary", value: "primary" },
                  { label: "Secondary", value: "secondary" },
                ],
              },
            ],
          },
        ],
      };
      
      export { HeroBlock, ContentBlock, CallToActionBlock };
      ```
      
      ```typescript
      // Usage in a collection
      import {
        HeroBlock,
        ContentBlock,
        CallToActionBlock,
      } from "../fields/layout-blocks";
      
      const Pages: CollectionConfig = {
        slug: "pages",
        fields: [
          { name: "title", type: "text", required: true },
          {
            name: "layout",
            type: "blocks",
            blocks: [HeroBlock, ContentBlock, CallToActionBlock],
          },
        ],
      };
      ```
      
      **Why good:** Blocks defined in separate files for reuse across collections, each block has a clear `slug` for identification, CTA block uses array for multiple buttons with constraints, blocks compose together for flexible page building
      
      ---
      
      ## Pattern 6: Relationship Fields — Polymorphic and Has-Many
      
      ### Good Example — Polymorphic Relationship
      
      ```typescript
      // A "related content" field that can reference posts OR pages
      {
        name: "relatedContent",
        type: "relationship",
        relationTo: ["posts", "pages"], // Polymorphic — multiple collection targets
        hasMany: true,
      }
      ```
      
      ### Good Example — Has-Many with Max
      
      ```typescript
      // Featured posts — limited to 5
      {
        name: "featuredPosts",
        type: "relationship",
        relationTo: "posts",
        hasMany: true,
        maxRows: 5,
        admin: {
          description: "Select up to 5 featured posts",
        },
      }
      ```
      
      ### Good Example — Self-Referencing Relationship
      
      ```typescript
      // Category hierarchy
      const Categories: CollectionConfig = {
        slug: "categories",
        admin: { useAsTitle: "name" },
        fields: [
          { name: "name", type: "text", required: true },
          {
            name: "parent",
            type: "relationship",
            relationTo: "categories", // Self-reference
          },
        ],
      };
      ```
      
      **Why good:** Polymorphic relationships reference multiple collections, `hasMany` with `maxRows` for bounded lists, self-referencing for hierarchical data, `admin.description` helps content editors
      
      ---
      
      _For globals, versions, uploads, auth, and API patterns, see [advanced.md](advanced.md)._
      
  • reference.md 8.4 KB
    # Payload CMS Reference
    
    > Quick lookup tables, CLI commands, type generation, and decision frameworks. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples.
    
    ---
    
    ## CLI Commands
    
    ### Project Setup
    
    ```bash
    # Create a new Payload project
    npx create-payload-app@latest
    
    # Start development server
    pnpm dev
    
    # Generate TypeScript types from your config
    pnpm payload generate:types
    ```
    
    ### Database Migrations (Postgres/SQLite)
    
    ```bash
    # Create a new migration
    pnpm payload migrate:create
    
    # Run pending migrations
    pnpm payload migrate
    
    # Check migration status
    pnpm payload migrate:status
    
    # Reset database (drops all tables, re-runs migrations)
    pnpm payload migrate:reset
    
    # Push schema directly (dev only — NOT for production)
    pnpm payload migrate:fresh
    ```
    
    ---
    
    ## Environment Variables
    
    ```bash
    # .env
    DATABASE_URL=postgres://user:password@localhost:5432/mydb
    # or
    DATABASE_URI=mongodb://localhost:27017/mydb
    
    PAYLOAD_SECRET=your-secret-key-min-32-chars
    ```
    
    ---
    
    ## Field Types Quick Reference
    
    | Type           | Data Shape         | Use For                                    |
    | -------------- | ------------------ | ------------------------------------------ |
    | `text`         | `string`           | Short text, titles, slugs                  |
    | `textarea`     | `string`           | Multi-line text, excerpts                  |
    | `richText`     | `object` (Lexical) | Rich content with formatting               |
    | `number`       | `number`           | Prices, quantities, ratings                |
    | `email`        | `string`           | Email addresses (validated)                |
    | `checkbox`     | `boolean`          | Toggle flags                               |
    | `date`         | `string` (ISO)     | Dates and timestamps                       |
    | `select`       | `string`           | Dropdown single choice                     |
    | `radio`        | `string`           | Radio button single choice                 |
    | `json`         | `object`           | Arbitrary JSON data                        |
    | `code`         | `string`           | Code snippets with syntax highlighting     |
    | `point`        | `[number, number]` | Geographic coordinates                     |
    | `relationship` | `string` (ID)      | Reference to another document              |
    | `upload`       | `string` (ID)      | Reference to a media/file document         |
    | `group`        | `object`           | Nested object with sub-fields              |
    | `array`        | `object[]`         | Repeatable rows of same-shape fields       |
    | `blocks`       | `object[]`         | Flexible content with multiple block types |
    | `tabs`         | _(layout only)_    | Tabbed sections in admin UI                |
    | `row`          | _(layout only)_    | Side-by-side fields in admin UI            |
    | `collapsible`  | _(layout only)_    | Collapsible section in admin UI            |
    
    ---
    
    ## Collection Hook Execution Order
    
    ```
    CREATE:
      beforeOperation → beforeValidate → beforeChange → [DB write] → afterChange → afterOperation
    
    UPDATE:
      beforeOperation → beforeValidate → beforeChange → [DB write] → afterChange → afterOperation
    
    DELETE:
      beforeOperation → beforeDelete → [DB delete] → afterDelete → afterOperation
    
    READ:
      beforeOperation → beforeRead → [DB read] → afterRead → afterOperation
    ```
    
    ---
    
    ## Hook Arguments Quick Reference
    
    | Hook              | Key Arguments                                    | Returns  |
    | ----------------- | ------------------------------------------------ | -------- |
    | `beforeValidate`  | `data`, `operation`, `originalDoc`, `req`        | `data`   |
    | `beforeChange`    | `data`, `operation`, `originalDoc`, `req`        | `data`   |
    | `afterChange`     | `doc`, `data`, `previousDoc`, `operation`, `req` | `doc`    |
    | `beforeRead`      | `doc`, `query`, `req`                            | `doc`    |
    | `afterRead`       | `doc`, `query`, `req`                            | `doc`    |
    | `beforeDelete`    | `id`, `req`                                      | _(void)_ |
    | `afterDelete`     | `doc`, `id`, `req`                               | _(void)_ |
    | `beforeOperation` | `collection`, `operation`, `req`                 | _(void)_ |
    | `afterOperation`  | `result`, `args`, `operation`, `req`             | `result` |
    
    **Auth-only hooks:** `beforeLogin`, `afterLogin`, `afterLogout`, `afterRefresh`, `afterMe`, `afterForgotPassword`
    
    ---
    
    ## Access Control Function Signatures
    
    | Operation | Arguments           | Returns            |
    | --------- | ------------------- | ------------------ |
    | `create`  | `{ req, data }`     | `boolean`          |
    | `read`    | `{ req, id }`       | `boolean \| Where` |
    | `update`  | `{ req, id, data }` | `boolean \| Where` |
    | `delete`  | `{ req, id }`       | `boolean \| Where` |
    | `admin`   | `{ req }`           | `boolean`          |
    | `unlock`  | `{ req }`           | `boolean`          |
    
    **Field access:** `create`, `read`, `update` — same pattern but on individual fields.
    
    **Where query return:** When an access function returns a `Where` query instead of a boolean, Payload appends it to the database query, scoping results without denying access entirely.
    
    ---
    
    ## Local API Operations
    
    | Operation          | Method                     | Returns                    |
    | ------------------ | -------------------------- | -------------------------- |
    | Find (paginated)   | `payload.find()`           | `{ docs, totalDocs, ... }` |
    | Find by ID         | `payload.findByID()`       | Single document            |
    | Create             | `payload.create()`         | Created document           |
    | Update by ID/where | `payload.update()`         | Updated document(s)        |
    | Delete by ID/where | `payload.delete()`         | Deleted document(s)        |
    | Count              | `payload.count()`          | `{ totalDocs }`            |
    | Read global        | `payload.findGlobal()`     | Global document            |
    | Update global      | `payload.updateGlobal()`   | Updated global             |
    | Login              | `payload.login()`          | `{ token, user, exp }`     |
    | Auth check         | `payload.auth()`           | `{ user, permissions }`    |
    | Restore version    | `payload.restoreVersion()` | Restored document          |
    
    **Common options:** `overrideAccess` (default: `true`), `depth`, `locale`, `select`, `where`, `sort`, `limit`, `page`
    
    ---
    
    ## REST API Endpoints
    
    | Method   | Endpoint                           | Operation        |
    | -------- | ---------------------------------- | ---------------- |
    | `GET`    | `/api/{slug}`                      | Find (paginated) |
    | `GET`    | `/api/{slug}/:id`                  | Find by ID       |
    | `POST`   | `/api/{slug}`                      | Create           |
    | `PATCH`  | `/api/{slug}/:id`                  | Update           |
    | `DELETE` | `/api/{slug}/:id`                  | Delete           |
    | `GET`    | `/api/{slug}/count`                | Count            |
    | `GET`    | `/api/globals/{slug}`              | Read global      |
    | `POST`   | `/api/globals/{slug}`              | Update global    |
    | `POST`   | `/api/{auth-slug}/login`           | Login            |
    | `POST`   | `/api/{auth-slug}/logout`          | Logout           |
    | `GET`    | `/api/{auth-slug}/me`              | Current user     |
    | `POST`   | `/api/{auth-slug}/forgot-password` | Forgot password  |
    | `POST`   | `/api/{auth-slug}/reset-password`  | Reset password   |
    
    ---
    
    ## TypeScript Type Generation
    
    ```bash
    # Generate types from your Payload config
    pnpm payload generate:types
    # Output: src/payload-types.ts (or path in config)
    ```
    
    ```typescript
    // Usage in your code
    import type { Post, User, Media } from "./payload-types";
    
    // Types are auto-generated from your collection configs
    // They update when you change fields and re-run generate:types
    ```
    
    ---
    
    ## Project Structure
    
    ```
    my-app/
      payload.config.ts          # Main Payload config
      src/
        payload-types.ts         # Auto-generated TypeScript types
        collections/
          posts.ts               # Collection config
          users.ts               # Auth collection config
          media.ts               # Upload collection config
        globals/
          site-settings.ts       # Global config
          navigation.ts          # Global config
        access/
          index.ts               # Reusable access control functions
        hooks/
          set-author.ts          # Hook: auto-set author
          revalidate-cache.ts    # Hook: cache invalidation
        fields/
          layout-blocks.ts       # Reusable block definitions
    ```
    
  • SKILL.md 18 KB
    ---
    name: api-cms-payload
    description: Payload CMS v3 — TypeScript-native headless CMS with code-first collections, hooks, access control, Local/REST/GraphQL APIs, admin panel, and database adapter pattern
    ---
    
    # Payload CMS Patterns
    
    > **Quick Guide:** Use Payload for code-first content management with TypeScript. Define collections and globals as config objects with typed fields, hooks, and access control functions. Prefer the Local API (`payload.find`, `payload.create`) for server-side operations. Always generate TypeScript types from your config. Use database adapters (Postgres or MongoDB) and never hardcode credentials. Access control functions receive `{ req }` with the authenticated user. Hooks run at the document lifecycle level (beforeChange, afterChange, etc.) and must not have side effects that block the request unless intentional.
    
    ---
    
    <critical_requirements>
    
    ## CRITICAL: Before Using This Skill
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST define access control on every collection — open collections are a security risk)**
    
    **(You MUST use the Local API (`payload.find`, `payload.create`) for server-side data operations — it is zero-latency and fully typed)**
    
    **(You MUST generate TypeScript types with `payload generate:types` after every schema change)**
    
    **(You MUST keep JSX/React component imports OUT of the Payload config file — separate config and UI concerns)**
    
    **(You MUST use `overrideAccess: false` when calling the Local API on behalf of a user — the default is `true` which bypasses all access control)**
    
    </critical_requirements>
    
    ---
    
    **Auto-detection:** Payload, payload, payloadcms, @payloadcms, buildConfig, CollectionConfig, GlobalConfig, payload.config.ts, payload.find, payload.create, payload.update, payload.delete, payload.findByID, lexicalEditor, richText, beforeChange, afterChange, afterRead, beforeValidate, access control payload, upload collection, imageSizes, versions drafts
    
    **When to use:**
    
    - Configuring `payload.config.ts` with database adapter, collections, and globals
    - Defining collection schemas with typed fields (text, richText, relationship, blocks, array, group, upload, select)
    - Implementing access control functions (role-based, ownership-based, field-level)
    - Writing collection hooks (beforeChange, afterChange, beforeRead, afterRead, beforeValidate, beforeDelete, afterDelete)
    - Querying data via Local API, REST API, or GraphQL
    - Setting up authentication collections with login, roles, and JWT
    - Configuring uploads/media with image sizes and mime type restrictions
    - Enabling versions and drafts on collections or globals
    - Customizing the admin panel (groups, hidden collections, custom components)
    
    **Key patterns covered:**
    
    - `payload.config.ts` setup with `buildConfig`, database adapters, editor config
    - Collection config: slug, fields, hooks, access, auth, upload, versions, admin
    - Field types: text, richText, relationship, upload, blocks, array, group, select, tabs, checkbox, date, number, email, code, json, point, radio, textarea, row, collapsible
    - Access control: collection-level and field-level, returning boolean or Where query
    - Hooks: beforeChange, afterChange, beforeRead, afterRead, beforeValidate, beforeDelete, afterDelete, beforeOperation, afterOperation
    - Local API: `payload.find`, `payload.findByID`, `payload.create`, `payload.update`, `payload.delete`, `payload.count`
    - REST API: auto-generated endpoints at `/api/{collection-slug}`
    - Globals: singleton documents for site settings, navigation, footer
    - Auth collections: `auth: true`, roles, login strategies
    - Uploads: imageSizes, mimeTypes, media collections
    - Versions and drafts: `versions: { drafts: true }`
    - TypeScript type generation
    
    **When NOT to use:**
    
    - Simple key-value storage (use a database directly)
    - Static site generation without content editing needs
    - Applications that only need a REST API without an admin panel (use a plain API framework)
    - Client-side data fetching patterns (Payload's Local API is server-only)
    
    **Detailed Resources:**
    
    - For decision frameworks and anti-patterns, see [reference.md](reference.md)
    
    **Core Setup & Collections:**
    
    - [examples/core.md](examples/core.md) — Config setup, collection definitions, field types, access control, hooks
    
    **Advanced Patterns:**
    
    - [examples/advanced.md](examples/advanced.md) — Globals, versions/drafts, uploads/media, auth collections, Local API, REST API
    
    ---
    
    <philosophy>
    
    ## Philosophy
    
    Payload is a TypeScript-native headless CMS that treats your schema as code. Instead of clicking through a GUI to build content models, you define collections and globals as TypeScript config objects. Payload auto-generates an admin panel, REST API, GraphQL API, and a fully typed Local API from your config.
    
    **Core principles:**
    
    1. **Config-as-code** -- Collections, globals, fields, hooks, and access control are all defined in TypeScript. Your schema is version-controlled, reviewable, and deployable like any other code.
    2. **Three APIs from one config** -- Every collection automatically gets a Local API (server-only, zero-latency), REST API (`/api/{slug}`), and GraphQL API. The Local API is the primary interface for server-side operations.
    3. **Access control is mandatory** -- Every collection should have explicit `access` functions. By default, Payload denies access to unauthenticated users, but you must define who can do what. Access functions can return a boolean or a `Where` query to scope results.
    4. **Hooks for side effects** -- Lifecycle hooks (beforeChange, afterChange, etc.) let you run logic at specific points in the document lifecycle. Keep hooks focused and avoid blocking operations unnecessarily.
    5. **Database-agnostic** -- Payload uses database adapters (Postgres or MongoDB). Your collections and fields are defined once and work with any supported database.
    6. **Type generation** -- Run `payload generate:types` to produce TypeScript interfaces from your config. This gives you end-to-end type safety from config to API responses.
    
    **When to use Payload:**
    
    - Content-managed applications (blogs, e-commerce, marketing sites)
    - Applications needing an admin panel with role-based access
    - Projects requiring a typed CMS with version control over the schema
    - Multi-tenant applications using access control to scope data per tenant
    - Headless CMS backing a frontend framework
    
    </philosophy>
    
    ---
    
    <patterns>
    
    ## Core Patterns
    
    ### Pattern 1: payload.config.ts Setup
    
    The config is the entry point. It defines the database adapter, collections, globals, editor, and admin settings. Always use env vars for credentials.
    
    ```typescript
    const config = buildConfig({
      db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URL } }),
      editor: lexicalEditor(),
      collections: [Posts, Users, Media],
      globals: [SiteSettings],
      admin: { user: Users.slug },
      typescript: { outputFile: "./src/payload-types.ts" },
      secret: process.env.PAYLOAD_SECRET!,
    });
    ```
    
    Never hardcode database URLs or secrets. Import collections from separate files. See [examples/core.md](examples/core.md) for full Postgres and MongoDB adapter configs.
    
    ---
    
    ### Pattern 2: Collection Config
    
    Collections are the primary data model. Each generates a database table, admin UI, and API endpoints. Define access control per operation, use `useAsTitle` for admin display, and keep each collection in its own file.
    
    ```typescript
    const Posts: CollectionConfig = {
      slug: "posts",
      admin: { useAsTitle: "title" },
      access: {
        read: () => true,
        create: ({ req: { user } }) => Boolean(user),
        update: isAdminOrAuthor,
        delete: isAdmin,
      },
      hooks: {
        beforeChange: [setAuthorOnCreate],
        afterChange: [revalidatePostCache],
      },
      versions: { drafts: true },
      fields: [
        { name: "title", type: "text", required: true },
        { name: "content", type: "richText" },
        {
          name: "author",
          type: "relationship",
          relationTo: "users",
          required: true,
        },
      ],
    };
    ```
    
    See [examples/core.md](examples/core.md) for full collection config with all field types, sidebar positioning, and SEO tabs.
    
    ---
    
    ### Pattern 3: Access Control
    
    Access functions receive `{ req }` with the authenticated user. They return `true`/`false` or a `Where` query to scope results. Define reusable functions in a shared `access/` directory.
    
    ```typescript
    // Return boolean for simple checks
    const isAdmin: Access = ({ req: { user } }) => user?.role === "admin";
    
    // Return Where query for scoped access — users see only their own documents
    const isAdminOrSelf: Access = ({ req: { user } }) => {
      if (!user) return false;
      if (user.role === "admin") return true;
      return { author: { equals: user.id } };
    };
    ```
    
    Field-level access uses the same pattern on individual fields. See [examples/core.md](examples/core.md) for reusable access functions and field-level access examples.
    
    ---
    
    ### Pattern 4: Collection Hooks
    
    Hooks run at specific points in the document lifecycle. `beforeChange` returns modified `data`, `afterChange` is for non-blocking side effects. Use `req.payload` to access the Local API within hooks. Hooks receive a `context` object to prevent infinite loops when hooks trigger other operations.
    
    ```typescript
    beforeChange: [
      ({ data, operation, req }) => {
        if (operation === "create" && req.user) data.author = req.user.id;
        return data;
      },
    ],
    afterChange: [
      ({ doc, operation, req, context }) => {
        if (context.skipRevalidation) return;
        if (operation === "create") req.payload.logger.info(`Post created: ${doc.title}`);
      },
    ],
    ```
    
    Never put blocking external API calls in `beforeChange` -- use `afterChange` for non-critical side effects. See [examples/core.md](examples/core.md) for hook patterns and [examples/advanced.md](examples/advanced.md) for cross-collection hooks.
    
    ---
    
    ### Pattern 5: Field Types
    
    Payload provides typed fields: `text`, `richText`, `number`, `select`, `checkbox`, `date`, `email`, `textarea`, `relationship`, `upload`, `json`, `code`, `point`, `radio`. Structural fields compose to model any content shape:
    
    - **group** -- nested object with sub-fields
    - **array** -- repeatable rows of same-shape fields
    - **blocks** -- flexible content with multiple block types (use `interfaceName` for custom TypeScript interface names)
    - **tabs**, **row**, **collapsible** -- admin-only layout helpers that do not affect data shape
    
    Use **blocks** when editors choose from multiple block types for flexible page layouts. Use **array** when every row has the same fields. See [examples/core.md](examples/core.md) for block definitions and [reference.md](reference.md) for the complete field type table.
    
    ---
    
    ### Pattern 6: Local API
    
    The Local API is the primary server-side interface -- zero-latency, fully typed, and executes hooks and access control. Always pass `overrideAccess: false` when operating on behalf of a user.
    
    ```typescript
    const payload = await getPayload({ config });
    const result = await payload.find({
      collection: "posts",
      where: { status: { equals: "published" } },
      sort: "-createdAt",
      limit: 20,
      depth: 1,
      overrideAccess: false,
    });
    ```
    
    Without `overrideAccess: false`, access control is completely bypassed (the default is `true`). See [examples/advanced.md](examples/advanced.md) for full CRUD operations, bulk updates, and globals API.
    
    </patterns>
    
    ---
    
    <decision_framework>
    
    ## Decision Framework
    
    ### Which API to Use
    
    ```
    Where is the code running?
    +-- Server-side (API route, server component, script)
    |   +-- Local API (zero-latency, fully typed, preferred)
    +-- External client (browser, mobile app, third-party)
    |   +-- REST API (/api/{collection-slug})
    +-- GraphQL client
        +-- GraphQL API (/api/graphql)
    ```
    
    ### Field Type Selection
    
    ```
    What kind of data?
    +-- Single value
    |   +-- Short text --> text
    |   +-- Long text --> textarea
    |   +-- Rich content --> richText
    |   +-- Number --> number
    |   +-- Boolean --> checkbox
    |   +-- Date/time --> date
    |   +-- Email --> email
    |   +-- Coordinates --> point
    |   +-- Code snippet --> code
    |   +-- Arbitrary JSON --> json
    +-- Choice from options
    |   +-- Single choice (dropdown) --> select
    |   +-- Single choice (visible) --> radio
    |   +-- Linked document --> relationship
    |   +-- File/image --> upload
    +-- Nested structure
    |   +-- Fixed group of fields --> group
    |   +-- Repeatable rows (same shape) --> array
    |   +-- Flexible content (multiple block types) --> blocks
    +-- Admin layout only (no data effect)
        +-- Tabbed sections --> tabs
        +-- Side-by-side fields --> row
        +-- Collapsible section --> collapsible
    ```
    
    ### Access Control Strategy
    
    ```
    Who should access this data?
    +-- Public (anyone) --> read: () => true
    +-- Authenticated users only --> read: ({ req: { user } }) => Boolean(user)
    +-- Admin only --> read: ({ req: { user } }) => user?.role === 'admin'
    +-- Owner only --> read: return Where query matching user.id
    +-- Mixed (public read, auth write) --> Different function per operation
    +-- Field-level restriction --> access on individual field config
    ```
    
    ### Hooks vs Access Control
    
    ```
    What do you need to do?
    +-- Control WHO can do something --> Access control
    +-- Control WHAT happens when they do it --> Hooks
    +-- Validate data before saving --> beforeValidate hook or field validation
    +-- Transform data before saving --> beforeChange hook
    +-- Trigger side effects after saving --> afterChange hook
    +-- Filter/transform output --> afterRead hook
    ```
    
    </decision_framework>
    
    ---
    
    <red_flags>
    
    ## RED FLAGS
    
    **High Priority Issues:**
    
    - **Missing access control on collections** -- Without explicit `access` functions, Payload denies all access to unauthenticated users but grants full access to any authenticated user. Always define explicit access rules.
    - **`overrideAccess` default is `true` in Local API** -- Every `payload.find()`, `payload.create()`, etc. call bypasses access control by default. Always pass `overrideAccess: false` when operating on behalf of a user.
    - **Importing JSX/React components in payload.config.ts** -- Payload config runs in a Node context. Importing React components (even transitively) causes bundling errors. Keep config and UI imports completely separate.
    - **Hardcoded `secret` or database URL** -- Use environment variables. The Payload secret is used to sign JWTs; hardcoding it is a security vulnerability.
    
    **Medium Priority Issues:**
    
    - **Using `select("*")` equivalent** -- In the Local API, not specifying `select` returns all fields. Use the `select` option to fetch only needed fields for performance.
    - **Deep `depth` values** -- Default depth is 2. High depth values cause cascading relationship queries. Set `depth: 0` or `depth: 1` unless you need deeply nested relationships.
    - **Blocking hooks with external calls** -- `beforeChange` and `beforeValidate` hooks block the save operation. Move non-critical external API calls to `afterChange` or use background processing.
    - **Not running `payload generate:types` after schema changes** -- Stale types lead to runtime errors that TypeScript should have caught at compile time.
    
    **Common Mistakes:**
    
    - **Deep-cloning collection configs** -- `JSON.parse(JSON.stringify(config))` strips hooks and access functions (they are functions, not serializable data). Use spread or Object.assign instead.
    - **Forgetting `.select()` equivalent after create/update** -- In the Local API, `payload.create` and `payload.update` return the full document by default. Use the `select` option if you need specific fields.
    - **Using `FOR ALL` style access** -- Define separate access functions for `create`, `read`, `update`, `delete` instead of a single function. Different operations have different security requirements.
    - **Monorepo version mismatches** -- All packages in a monorepo must use the same version of `payload`, `@payloadcms/*`, `next`, `react`, and `react-dom`. Mismatches cause subtle bundling errors.
    
    **Gotchas & Edge Cases:**
    
    - **`beforeChange` data is a partial on update** -- On `update` operations, `data` contains only the changed fields, not the full document. Use `originalDoc` to access existing values.
    - **`beforeChange` has no `id` on create** -- The document ID is not available during `beforeChange` on create operations. If you need the ID, use `afterChange`.
    - **`overrideAccess` defaults** -- Local API defaults to `true` (bypass access control). REST and GraphQL always enforce access control. This asymmetry is intentional but catches people off guard.
    - **Tabs, rows, and collapsibles do not affect data shape** -- These are admin-only layout fields. A field inside a `tab` is stored at the top level of the document, not nested.
    - **Relationship depth cascading** -- Setting `depth: 3` on a collection with circular relationships can cause exponential query growth. Keep depth as low as possible.
    - **Auth collections auto-inject fields** -- Collections with `auth: true` automatically get `email`, `hash`, `salt`, `loginAttempts`, and `lockUntil` fields. Do not redefine them.
    - **Versions create a separate table** -- Enabling `versions: true` creates a `_posts_versions` table (or equivalent). This can significantly increase storage for high-traffic collections.
    - **Access control `Where` queries run as SQL** -- When an access function returns a `Where` query instead of a boolean, it is appended to the database query. Complex `Where` queries can impact database performance.
    
    </red_flags>
    
    ---
    
    <critical_reminders>
    
    ## CRITICAL REMINDERS
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST define access control on every collection — open collections are a security risk)**
    
    **(You MUST use the Local API (`payload.find`, `payload.create`) for server-side data operations — it is zero-latency and fully typed)**
    
    **(You MUST generate TypeScript types with `payload generate:types` after every schema change)**
    
    **(You MUST keep JSX/React component imports OUT of the Payload config file — separate config and UI concerns)**
    
    **(You MUST use `overrideAccess: false` when calling the Local API on behalf of a user — the default is `true` which bypasses all access control)**
    
    **Failure to follow these rules will create security vulnerabilities, type-unsafe operations, and bundling errors.**
    
    </critical_reminders>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related