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
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-cms-payload/skills/api-cms-payload
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
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.tswith 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.tssetup withbuildConfig, 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
accessfunctions, Payload denies all access to unauthenticated users but grants full access to any authenticated user. Always define explicit access rules. overrideAccessdefault istruein Local API -- Everypayload.find(),payload.create(), etc. call bypasses access control by default. Always passoverrideAccess: falsewhen 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
secretor 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 specifyingselectreturns all fields. Use theselectoption to fetch only needed fields for performance. - Deep
depthvalues -- Default depth is 2. High depth values cause cascading relationship queries. Setdepth: 0ordepth: 1unless you need deeply nested relationships. - Blocking hooks with external calls --
beforeChangeandbeforeValidatehooks block the save operation. Move non-critical external API calls toafterChangeor use background processing. - Not running
payload generate:typesafter 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.createandpayload.updatereturn the full document by default. Use theselectoption if you need specific fields. - Using
FOR ALLstyle access -- Define separate access functions forcreate,read,update,deleteinstead 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, andreact-dom. Mismatches cause subtle bundling errors.
Gotchas & Edge Cases:
beforeChangedata is a partial on update -- Onupdateoperations,datacontains only the changed fields, not the full document. UseoriginalDocto access existing values.beforeChangehas noidon create -- The document ID is not available duringbeforeChangeon create operations. If you need the ID, useafterChange.overrideAccessdefaults -- Local API defaults totrue(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
tabis stored at the top level of the document, not nested. - Relationship depth cascading -- Setting
depth: 3on a collection with circular relationships can cause exponential query growth. Keep depth as low as possible. - Auth collections auto-inject fields -- Collections with
auth: trueautomatically getemail,hash,salt,loginAttempts, andlockUntilfields. Do not redefine them. - Versions create a separate table -- Enabling
versions: truecreates a_posts_versionstable (or equivalent). This can significantly increase storage for high-traffic collections. - Access control
Wherequeries run as SQL -- When an access function returns aWherequery instead of a boolean, it is appended to the database query. ComplexWherequeries 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.
Reviews (0)
No reviews yet.
No comments yet.