payloadcms-ops
Payload CMS 3 (Next.js-native) architecture - collections, globals, fields, access control, hooks, Local API, storage adapters, and database (Postgres/MongoDB/SQLite). Use for: payload, payloadcms, payload cms, payload 3, collection config, access control, payload hooks, local ap
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/payloadcms-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Payload CMS Operations
Authoritative reference for Payload 3.x — the Next.js-native, TypeScript-first headless CMS. Payload 3 installs into a Next.js (App Router) app and gives you an auto-generated admin panel, REST + GraphQL APIs, a typed Local API, authentication, access control, file storage, and live preview — one open-source TypeScript codebase.
Version note (verified against payloadcms.com/docs, 2026-06): Payload 3 is the Next.js fullstack framework — there is no standalone Express server anymore. The config lives at
src/payload.config.ts; Payload mounts into the Next App Router via the installed(payload)route group. Don't ship Payload 2.x "standalone Express app" guidance.
Architecture at a glance
| Piece | What it is |
|---|---|
| payload.config.ts | Single source of truth: collections, globals, db adapter, plugins, admin, auth |
| Collections | Repeatable document groups (Posts, Users, Media) — the core building block |
| Globals | Singletons (one document) — site settings, header/footer nav |
| Fields | Compose document shape; also drive admin UI, validation, access |
| Local API | Typed, in-process data access (payload.find(...)) — no HTTP, runs server-side |
| REST / GraphQL | Auto-generated HTTP APIs over the same collections |
| Database adapter | @payloadcms/db-postgres, db-mongodb, or db-sqlite |
| Storage adapter | Local disk (dev) or S3/R2/etc. for uploads |
Where it lives in a Next.js app
src/
├── payload.config.ts # the config — collections, globals, db, plugins
├── collections/ # one file per CollectionConfig
│ ├── Users.ts
│ ├── Posts.ts
│ └── Media.ts
├── globals/ # GlobalConfig files
└── app/
├── (payload)/ # Payload's admin + API route group (generated)
└── (frontend)/ # your Next.js front end — uses the Local API
Collections — the core shape
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts', // required, URL-safe identifier
admin: { useAsTitle: 'title', defaultColumns: ['title', 'status'] },
access: { // see access-control reference
read: () => true,
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => req.user?.role === 'admin',
},
versions: { drafts: true }, // draft/publish + revision history
hooks: { /* lifecycle — see hooks reference */ },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true, index: true },
{ name: 'content', type: 'richText' },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
}
| Collection property | Purpose |
|---|---|
slug |
Required identifier (and REST/GraphQL route base) |
fields |
Required — document shape + UI + validation |
access |
Per-operation authorization (read/create/update/delete) |
hooks |
Lifecycle entry points (before/after change/read/delete) |
admin |
Admin-panel UI (title field, columns, components, groups) |
auth |
Turns the collection into an auth collection (e.g. Users) |
upload |
Makes it an upload collection (file storage, image sizes) |
versions |
Drafts + revision history |
Globals vs Collections
"If your Collection is only ever meant to contain a single Document, consider using a Global instead."
Globals (GlobalConfig) are singletons — site settings, main nav. Same fields/access/hooks/admin surface, one document.
Fields (the 80/20)
| Type | Use for |
|---|---|
text, textarea, number, email, date, checkbox |
Scalars |
richText |
Lexical-based rich content |
select, radio |
Enumerations |
relationship |
Link to other collections (relationTo, hasMany) |
upload |
Reference an upload collection (media) |
array |
Repeatable sub-field groups |
blocks |
Flexible content — choose from defined block types per row |
group |
Nested namespaced fields |
row, collapsible, tabs |
Admin layout only (no data nesting except tabs with name) |
json, code |
Raw structured/code data |
Every field can carry access, hooks, validate, admin.condition (conditional display), and localized: true for i18n. See references/hooks-and-fields.md.
Access control — least privilege by default
Access functions return boolean or a query constraint (row-level filtering). They run for Local API, REST, and GraphQL uniformly.
access: {
// boolean: can this user perform the op at all?
delete: ({ req }) => req.user?.role === 'admin',
// query constraint: WHICH documents can they read? (row-level)
read: ({ req }) => {
if (req.user?.role === 'admin') return true
return { author: { equals: req.user?.id } } // only their own
},
}
- Collection-level (read/create/update/delete) and field-level (
field.access.read/create/update) both exist — use field-level to hide/lock individual fields. - Never bypass access control in custom endpoints. Use
reqcontext; don't hand-roll DB calls that skip it. - The Local API can run with
overrideAccess: truefor trusted server code — use deliberately, not by default.
Full patterns (RBAC, multi-tenant isolation, field-level): references/access-control.md.
Hooks — lifecycle entry points
hooks: {
beforeChange: [({ data, req, operation }) => { /* mutate before save */ return data }],
afterChange: [({ doc, req, operation }) => { /* side effects: revalidate, notify */ return doc }],
beforeRead: [/* ... */],
afterRead: [/* shape outgoing doc */],
beforeDelete: [/* ... */],
afterDelete: [/* cleanup */],
}
Common use: in afterChange, call Next.js revalidatePath() / revalidateTag() to bust the front-end cache on publish. Since Next.js 16 that is revalidateTag('posts', 'max') — the second argument is a cacheLife profile, and the single-argument form is deprecated (on Next 15 it is the only form, and passing two arguments is a TypeScript error). Full hook catalog (collection, field, global, auth hooks) and the version table: references/hooks-and-fields.md.
Local API (the Next.js superpower)
In server components / route handlers, fetch data in-process — no HTTP round trip, fully typed:
import { getPayload } from 'payload'
import config from '@payload-config'
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
depth: 1, // auto-populate relationships one level deep
limit: 10,
})
payload.find / findByID / create / update / delete / findGlobal mirror the REST surface. Access control still applies unless overrideAccess: true.
Caching in Next.js
- Tag Local API reads —
'use cache'+cacheTagon Next 16,unstable_cache(..., { tags })on 15 — then invalidate from anafterChangehook viarevalidateTag(tag, 'max'). - Don't use
updateTag()in a Payload hook: it is Server-Actions-only, and Payload's REST/GraphQL writes run in a Route Handler where it throws. depthcontrols relationship population — keep it low to avoid over-fetching.
Decision tables
Database adapter
| Choice | Pick when |
|---|---|
Postgres (db-postgres) |
Relational data, SQL reporting, Vercel Postgres/Neon/Supabase; migrations matter |
MongoDB (db-mongodb) |
Document-shaped data, flexible schema, existing Mongo infra |
SQLite (db-sqlite) |
Local/edge, small footprint, simple deploys |
Storage adapter
| Choice | Pick when |
|---|---|
| Local disk | Dev only — not for serverless (ephemeral FS) |
S3 / R2 (@payloadcms/storage-s3) |
Production; put a CDN (CloudFront/Cloudflare) in front; signed URLs for private media; handle 403 on the frontend |
Multi-tenancy
| Approach | Pick when |
|---|---|
@payloadcms/plugin-multi-tenant |
Standard tenant isolation by a tenant field |
| Custom access constraints | Bespoke isolation rules; enforce via row-level read/update constraints |
Common gotchas
| Gotcha | Why | Fix |
|---|---|---|
| Users see data they shouldn't | read access returns true (no row filter) |
Return a query constraint from read, not just true |
| Local disk uploads vanish on Vercel | Serverless FS is ephemeral | Use S3/R2 storage adapter |
| Stale front-end after publish | Next.js caches the read | revalidateTag(tag, 'max') / revalidatePath in an afterChange hook |
updateTag throws in a hook |
It is Server-Actions-only; Payload's API writes run in a Route Handler | Use revalidateTag(tag, 'max'), or { expire: 0 } if it must not serve stale |
| S3 signed URL 403s on frontend | URLs expire | Handle 403 gracefully; refresh URL |
| Over-deep relationship fetch | High depth populates everything |
Keep depth minimal; populate explicitly |
| Custom endpoint leaks data | Bypassed access control | Go through Local API with access on; reserve overrideAccess for trusted paths |
| Env not validated | Misconfig fails at runtime | Validate env (zod) at boot |
| No real-time collab | Payload has no built-in CRDT | Pair with Liveblocks/Yjs; Payload stays source of truth for final state |
Assets
| File | Use |
|---|---|
assets/collection.config.template.ts |
Heavily commented Payload 3 CollectionConfig starter (access + hooks + fields), with adapt-points marked |
See also
typescript-ops— typing config, generated types (payload generate:types)react-ops— custom admin components, server components consuming the Local APIapi-design-ops— REST/GraphQL surface design, pagination, versioningauth-ops— auth collections, sessions/JWT, RBAC/ABAC patterns behind access control
Key external resources
Files (claude-mods)
-
assets
-
collection.config.template.ts 3.5 KB
/** * Payload 3 CollectionConfig starter — copy into src/collections/<Name>.ts and adapt. * * ADAPT-POINTS are marked with // ADAPT: * Register the export in src/payload.config.ts under `collections: [ ... ]`. * After editing schema, run `payload generate:types`. * * Verified against payloadcms.com/docs (Payload 3.x, Next.js-native). 2026-06. */ import type { CollectionConfig } from 'payload' export const Posts: CollectionConfig = { // ADAPT: URL-safe identifier; also the REST/GraphQL route base + relationTo target. slug: 'posts', admin: { useAsTitle: 'title', // ADAPT: which field labels rows in the admin list defaultColumns: ['title', 'status', 'updatedAt'], group: 'Content', // optional sidebar grouping }, // Draft/publish + revision history. Remove if you don't need drafts. versions: { drafts: true }, /** * Access control runs uniformly across Local API, REST, and GraphQL. * Return a boolean OR a `where` query constraint (row-level filtering). * A `read` returning `true` exposes EVERY row — return a constraint to scope. */ access: { read: ({ req }) => { if (req.user?.role === 'admin') return true if (req.user) return { author: { equals: req.user.id } } // ADAPT: ownership rule return { _status: { equals: 'published' } } // anon: published only }, create: ({ req }) => Boolean(req.user), update: ({ req }) => req.user?.role === 'admin' ? true : { author: { equals: req.user?.id } }, delete: ({ req }) => req.user?.role === 'admin', // ADAPT: who may delete }, hooks: { beforeChange: [ ({ data, req, operation }) => { if (operation === 'create' && req.user) data.author = req.user.id return data }, ], afterChange: [ async ({ doc }) => { // Bust the Next.js front-end cache on publish/edit. // // Next.js 16 signature: revalidateTag(tag, cacheLifeProfile). 'max' gives // stale-while-revalidate - readers keep getting the old page while the // rebuild runs. Use { expire: 0 } instead when the edit must be visible // on the very next request and a blocking revalidate is acceptable. // // NOT updateTag(): that is Server-Actions-only and throws elsewhere. // Payload mounts its REST/GraphQL under a Route Handler, so a hook fired // from the admin panel or the REST API is not in a Server Action. // // On Next.js 15, drop the second argument - the two-arg call is a // TypeScript error there (the single-arg form is what 15 ships). const { revalidateTag } = await import('next/cache') revalidateTag('posts', 'max') // ADAPT: tag your front end reads with return doc }, ], }, fields: [ { name: 'title', type: 'text', required: true }, { name: 'slug', type: 'text', unique: true, index: true, hooks: { beforeValidate: [ ({ value, data }) => value || data?.title?.toLowerCase().replace(/\s+/g, '-'), ], }, }, { name: 'content', type: 'richText' }, { name: 'author', type: 'relationship', relationTo: 'users', // ADAPT: must match your auth collection slug }, // Field-level access: lock a field independent of the document. { name: 'internalNotes', type: 'textarea', access: { read: ({ req }) => req.user?.role === 'admin', update: ({ req }) => req.user?.role === 'admin', }, }, ], }
-
-
references
-
access-control.md 3.5 KB
# Access Control — deep dive (Payload 3) Load when designing authorization: RBAC, multi-tenant isolation, or field-level locks. Access functions run uniformly across the Local API, REST, and GraphQL. They return either a `boolean` (can the user do this operation at all?) or a **query constraint** object (row-level: *which* documents). Returning a constraint from `read`/`update`/`delete` is the mechanism for per-tenant / per-owner data isolation — `true` alone means "all rows". ## Operation-level (collection) access ```typescript import type { CollectionConfig } from 'payload' export const Posts: CollectionConfig = { slug: 'posts', access: { read: ({ req }) => { if (!req.user) return { status: { equals: 'published' } } // anon sees published only if (req.user.role === 'admin') return true return { author: { equals: req.user.id } } // authors see their own }, create: ({ req }) => Boolean(req.user), update: ({ req }) => req.user?.role === 'admin' ? true : { author: { equals: req.user?.id } }, delete: ({ req }) => req.user?.role === 'admin', }, fields: [/* ... */], } ``` | Access fn | Controls | Returns | |-----------|----------|---------| | `read` | Listing + reading docs | bool or `where` constraint | | `create` | New docs | bool | | `update` | Editing | bool or `where` constraint | | `delete` | Removal | bool or `where` constraint | | `admin` | Whether user can access the admin panel (auth collection) | bool | | `unlock`, `readVersions` | Auth/versioning specifics | bool | ## Field-level access Lock or hide individual fields independent of the document: ```typescript { name: 'internalNotes', type: 'textarea', access: { read: ({ req }) => req.user?.role === 'admin', update: ({ req }) => req.user?.role === 'admin', create: ({ req }) => req.user?.role === 'admin', }, } ``` Field-level `read` false → field omitted from output. `update`/`create` false → field is read-only / cannot be set even if the document is writable. ## RBAC pattern Store a `role` (or `roles` hasMany) on the Users (auth) collection, then branch in access functions. Centralize predicates so they're reused, not copy-pasted: ```typescript // access/isAdmin.ts import type { Access } from 'payload' export const isAdmin: Access = ({ req }) => req.user?.role === 'admin' export const isAdminOrSelf: Access = ({ req }) => req.user?.role === 'admin' ? true : { author: { equals: req.user?.id } } ``` ## Multi-tenant isolation Two routes: 1. **`@payloadcms/plugin-multi-tenant`** — adds a tenant field + scoping automatically. Prefer this for standard cases. 2. **Custom constraints** — add a `tenant` relationship field, then enforce in every collection's access: ```typescript read: ({ req }) => ({ tenant: { equals: req.user?.tenant } }), ``` Apply the same constraint to `create` (force-set tenant in a `beforeChange` hook), `update`, and `delete`. Test that a user from tenant A genuinely cannot read/modify tenant B's rows — this is the #1 access bug. ## Rules - **Never bypass access control in custom endpoints.** Route through the Local API with access enabled. Reserve `overrideAccess: true` for trusted server-side jobs that *intentionally* run as system. - A `read` that returns `true` exposes every row. If data should be scoped, return a constraint, not a boolean. - Field access runs *in addition* to collection access — both must pass. - Access functions can be async (e.g. look up tenant membership) — return a Promise. -
hooks-and-fields.md 5.2 KB
# Hooks & Fields — deep dive (Payload 3) Load when wiring lifecycle side effects (cache revalidation, derived data, notifications) or composing non-trivial field structures (blocks, arrays, conditional/localized fields). ## Hooks Hooks are arrays of functions run at lifecycle points. They exist at four levels: **collection**, **field**, **global**, and **auth**. ### Collection hooks | Hook | Fires | Typical use | |------|-------|-------------| | `beforeValidate` | Before validation | Normalize/derive input | | `beforeChange` | Before create/update write | Mutate `data`, set derived fields | | `afterChange` | After write | Revalidate cache, send notifications, sync external | | `beforeRead` | Before a doc is read | Inject query context | | `afterRead` | After read, before return | Shape outgoing doc, computed fields | | `beforeDelete` / `afterDelete` | Around deletion | Cascade cleanup, remove files | | `afterOperation` | After any operation | Generic post-processing | ```typescript hooks: { beforeChange: [ ({ data, req, operation }) => { if (operation === 'create') data.createdBy = req.user?.id return data }, ], afterChange: [ async ({ doc, req }) => { // bust Next.js cache for this content on publish (Next 16 two-arg form) const { revalidateTag } = await import('next/cache') revalidateTag(`posts`, 'max') return doc }, ], } ``` ### Field hooks Same `beforeValidate / beforeChange / afterChange / afterRead` lifecycle but scoped to a single field — use for per-field derivation (e.g. auto-slug from title): ```typescript { name: 'slug', type: 'text', hooks: { beforeValidate: [({ value, data }) => value || data?.title?.toLowerCase().replace(/\s+/g, '-')], }, } ``` ### Auth hooks `beforeLogin`, `afterLogin`, `afterLogout`, `afterMe`, `afterRefresh`, `afterForgotPassword` — hook into the auth collection's session lifecycle. ### Cache-invalidation pattern (the canonical Next.js use) 1. Tag the front-end read: `'use cache'` + `cacheTag('posts')` on Next 16, or `unstable_cache(..., { tags: ['posts'] })` on 15 (`unstable_cache` still runs on 16, but the docs now point at `use cache`). 2. In the collection's `afterChange` (and `afterDelete`), call `revalidateTag('posts', 'max')`. 3. Publish/edit now busts exactly the affected cache entry. #### Which Next.js major (verified against nextjs.org, Next 16.3.3, 2026-08) `revalidateTag` takes a **cacheLife profile as a second argument since Next.js 16**. The single-argument form still runs but is deprecated. | Next.js | Call | Behaviour | |---|---|---| | 16 | `revalidateTag('posts', 'max')` | Stale-while-revalidate — readers are served the old page for up to a year while the rebuild runs. The recommended default. | | 16 | `revalidateTag('posts', { expire: 0 })` | No stale content; the next request blocks on a fresh fetch. Use when the edit must be visible immediately. | | 15 | `revalidateTag('posts')` | The only signature 15 has. Passing a second argument is a **TypeScript error** on 15, so don't ship the two-arg form to a 15 app. | **Do not reach for `updateTag()` here.** It is the Next 16 read-your-own-writes API, but it can *only* be called from inside a Server Action and throws anywhere else. Payload mounts its REST and GraphQL surface under a Route Handler, so an `afterChange` triggered by the admin panel or an API write is not in a Server Action — `revalidateTag` is the correct call in a Payload hook. `updateTag` only applies when *your own* Server Action calls the Local API directly. Payload 3 does not pin a Next.js major, so check the host app's `next` version before copying either form. The `nextjs-ops` skill carries the full caching model. ## Fields — composition patterns ### Blocks (flexible content) ```typescript { name: 'layout', type: 'blocks', blocks: [ { slug: 'hero', fields: [ { name: 'heading', type: 'text' }, { name: 'image', type: 'upload', relationTo: 'media' }, ], }, { slug: 'richText', fields: [{ name: 'content', type: 'richText' }], }, ], } ``` Each row stores a `blockType`; branch on it when rendering. This is Payload's equivalent of flexible page builders. ### Array fields ```typescript { name: 'features', type: 'array', minRows: 1, fields: [ { name: 'label', type: 'text' }, { name: 'icon', type: 'text' }, ], } ``` ### Conditional display ```typescript { name: 'externalUrl', type: 'text', admin: { condition: (data) => data.linkType === 'external' }, } ``` ### Localization (i18n) Set `localized: true` on any field; Payload stores per-locale values. Configure `locales` in `payload.config.ts`. Query a locale via Local API `locale` param. ### Field access & validation Every field accepts: - `access: { read, create, update }` — field-level authorization (see access-control.md) - `validate: (value, { data, req }) => true | 'error message'` - `defaultValue`, `required`, `unique`, `index` - `hooks` — field-scoped lifecycle (above) ## Generated types Run `payload generate:types` after schema changes to produce a typed `payload-types.ts`. Import the generated interfaces in front-end code so Local API results are fully typed.
-
-
scripts
-
.gitkeep 0 B · in bundle
-
-
SKILL.md 11.2 KB
--- name: payloadcms-ops description: "Payload CMS 3 (Next.js-native) architecture - collections, globals, fields, access control, hooks, Local API, storage adapters, and database (Postgres/MongoDB/SQLite). Use for: payload, payloadcms, payload cms, payload 3, collection config, access control, payload hooks, local api, payload fields, multi-tenant payload, payload nextjs, payload s3, payload r2, payloadcms architecture, headless cms typescript." license: MIT allowed-tools: "Read Write Bash" metadata: author: claude-mods related-skills: typescript-ops, react-ops, api-design-ops, auth-ops --- # Payload CMS Operations Authoritative reference for **Payload 3.x** — the Next.js-native, TypeScript-first headless CMS. Payload 3 **installs into a Next.js (App Router) app** and gives you an auto-generated admin panel, REST + GraphQL APIs, a typed Local API, authentication, access control, file storage, and live preview — one open-source TypeScript codebase. > **Version note (verified against payloadcms.com/docs, 2026-06):** Payload 3 is the **Next.js fullstack framework** — there is no standalone Express server anymore. The config lives at `src/payload.config.ts`; Payload mounts into the Next App Router via the installed `(payload)` route group. Don't ship Payload 2.x "standalone Express app" guidance. --- ## Architecture at a glance | Piece | What it is | |-------|-----------| | **payload.config.ts** | Single source of truth: collections, globals, db adapter, plugins, admin, auth | | **Collections** | Repeatable document groups (Posts, Users, Media) — the core building block | | **Globals** | Singletons (one document) — site settings, header/footer nav | | **Fields** | Compose document shape; also drive admin UI, validation, access | | **Local API** | Typed, in-process data access (`payload.find(...)`) — no HTTP, runs server-side | | **REST / GraphQL** | Auto-generated HTTP APIs over the same collections | | **Database adapter** | `@payloadcms/db-postgres`, `db-mongodb`, or `db-sqlite` | | **Storage adapter** | Local disk (dev) or S3/R2/etc. for uploads | ### Where it lives in a Next.js app ``` src/ ├── payload.config.ts # the config — collections, globals, db, plugins ├── collections/ # one file per CollectionConfig │ ├── Users.ts │ ├── Posts.ts │ └── Media.ts ├── globals/ # GlobalConfig files └── app/ ├── (payload)/ # Payload's admin + API route group (generated) └── (frontend)/ # your Next.js front end — uses the Local API ``` --- ## Collections — the core shape ```typescript import type { CollectionConfig } from 'payload' export const Posts: CollectionConfig = { slug: 'posts', // required, URL-safe identifier admin: { useAsTitle: 'title', defaultColumns: ['title', 'status'] }, access: { // see access-control reference read: () => true, create: ({ req }) => Boolean(req.user), update: ({ req }) => Boolean(req.user), delete: ({ req }) => req.user?.role === 'admin', }, versions: { drafts: true }, // draft/publish + revision history hooks: { /* lifecycle — see hooks reference */ }, fields: [ { name: 'title', type: 'text', required: true }, { name: 'slug', type: 'text', unique: true, index: true }, { name: 'content', type: 'richText' }, { name: 'author', type: 'relationship', relationTo: 'users' }, ], } ``` | Collection property | Purpose | |---------------------|---------| | `slug` | Required identifier (and REST/GraphQL route base) | | `fields` | Required — document shape + UI + validation | | `access` | Per-operation authorization (read/create/update/delete) | | `hooks` | Lifecycle entry points (before/after change/read/delete) | | `admin` | Admin-panel UI (title field, columns, components, groups) | | `auth` | Turns the collection into an auth collection (e.g. Users) | | `upload` | Makes it an upload collection (file storage, image sizes) | | `versions` | Drafts + revision history | ### Globals vs Collections > *"If your Collection is only ever meant to contain a single Document, consider using a Global instead."* Globals (`GlobalConfig`) are singletons — site settings, main nav. Same `fields`/`access`/`hooks`/`admin` surface, one document. --- ## Fields (the 80/20) | Type | Use for | |------|---------| | `text`, `textarea`, `number`, `email`, `date`, `checkbox` | Scalars | | `richText` | Lexical-based rich content | | `select`, `radio` | Enumerations | | `relationship` | Link to other collections (`relationTo`, `hasMany`) | | `upload` | Reference an upload collection (media) | | `array` | Repeatable sub-field groups | | `blocks` | Flexible content — choose from defined block types per row | | `group` | Nested namespaced fields | | `row`, `collapsible`, `tabs` | Admin layout only (no data nesting except `tabs` with `name`) | | `json`, `code` | Raw structured/code data | Every field can carry `access`, `hooks`, `validate`, `admin.condition` (conditional display), and `localized: true` for i18n. See `references/hooks-and-fields.md`. --- ## Access control — least privilege by default Access functions return `boolean` **or a query constraint** (row-level filtering). They run for Local API, REST, and GraphQL uniformly. ```typescript access: { // boolean: can this user perform the op at all? delete: ({ req }) => req.user?.role === 'admin', // query constraint: WHICH documents can they read? (row-level) read: ({ req }) => { if (req.user?.role === 'admin') return true return { author: { equals: req.user?.id } } // only their own }, } ``` - **Collection-level** (read/create/update/delete) and **field-level** (`field.access.read/create/update`) both exist — use field-level to hide/lock individual fields. - **Never bypass access control in custom endpoints.** Use `req` context; don't hand-roll DB calls that skip it. - The Local API can run with `overrideAccess: true` for trusted server code — use deliberately, not by default. Full patterns (RBAC, multi-tenant isolation, field-level): `references/access-control.md`. --- ## Hooks — lifecycle entry points ```typescript hooks: { beforeChange: [({ data, req, operation }) => { /* mutate before save */ return data }], afterChange: [({ doc, req, operation }) => { /* side effects: revalidate, notify */ return doc }], beforeRead: [/* ... */], afterRead: [/* shape outgoing doc */], beforeDelete: [/* ... */], afterDelete: [/* cleanup */], } ``` Common use: in `afterChange`, call Next.js `revalidatePath()` / `revalidateTag()` to bust the front-end cache on publish. Since Next.js 16 that is `revalidateTag('posts', 'max')` — the second argument is a cacheLife profile, and the single-argument form is deprecated (on Next 15 it is the only form, and passing two arguments is a TypeScript error). Full hook catalog (collection, field, global, auth hooks) and the version table: `references/hooks-and-fields.md`. --- ## Local API (the Next.js superpower) In server components / route handlers, fetch data in-process — no HTTP round trip, fully typed: ```typescript import { getPayload } from 'payload' import config from '@payload-config' const payload = await getPayload({ config }) const { docs } = await payload.find({ collection: 'posts', where: { status: { equals: 'published' } }, depth: 1, // auto-populate relationships one level deep limit: 10, }) ``` `payload.find / findByID / create / update / delete / findGlobal` mirror the REST surface. Access control still applies unless `overrideAccess: true`. ### Caching in Next.js - Tag Local API reads — `'use cache'` + `cacheTag` on Next 16, `unstable_cache(..., { tags })` on 15 — then invalidate from an `afterChange` hook via `revalidateTag(tag, 'max')`. - Don't use `updateTag()` in a Payload hook: it is Server-Actions-only, and Payload's REST/GraphQL writes run in a Route Handler where it throws. - `depth` controls relationship population — keep it low to avoid over-fetching. --- ## Decision tables ### Database adapter | Choice | Pick when | |--------|-----------| | **Postgres** (`db-postgres`) | Relational data, SQL reporting, Vercel Postgres/Neon/Supabase; migrations matter | | **MongoDB** (`db-mongodb`) | Document-shaped data, flexible schema, existing Mongo infra | | **SQLite** (`db-sqlite`) | Local/edge, small footprint, simple deploys | ### Storage adapter | Choice | Pick when | |--------|-----------| | Local disk | Dev only — not for serverless (ephemeral FS) | | S3 / R2 (`@payloadcms/storage-s3`) | Production; put a CDN (CloudFront/Cloudflare) in front; signed URLs for private media; handle 403 on the frontend | ### Multi-tenancy | Approach | Pick when | |----------|-----------| | `@payloadcms/plugin-multi-tenant` | Standard tenant isolation by a tenant field | | Custom access constraints | Bespoke isolation rules; enforce via row-level `read`/`update` constraints | --- ## Common gotchas | Gotcha | Why | Fix | |--------|-----|-----| | Users see data they shouldn't | `read` access returns `true` (no row filter) | Return a **query constraint** from `read`, not just `true` | | Local disk uploads vanish on Vercel | Serverless FS is ephemeral | Use S3/R2 storage adapter | | Stale front-end after publish | Next.js caches the read | `revalidateTag(tag, 'max')` / `revalidatePath` in an `afterChange` hook | | `updateTag` throws in a hook | It is Server-Actions-only; Payload's API writes run in a Route Handler | Use `revalidateTag(tag, 'max')`, or `{ expire: 0 }` if it must not serve stale | | S3 signed URL 403s on frontend | URLs expire | Handle 403 gracefully; refresh URL | | Over-deep relationship fetch | High `depth` populates everything | Keep `depth` minimal; populate explicitly | | Custom endpoint leaks data | Bypassed access control | Go through Local API with access on; reserve `overrideAccess` for trusted paths | | Env not validated | Misconfig fails at runtime | Validate env (zod) at boot | | No real-time collab | Payload has no built-in CRDT | Pair with Liveblocks/Yjs; Payload stays source of truth for final state | --- ## Assets | File | Use | |------|-----| | `assets/collection.config.template.ts` | Heavily commented Payload 3 CollectionConfig starter (access + hooks + fields), with adapt-points marked | --- ## See also - `typescript-ops` — typing config, generated types (`payload generate:types`) - `react-ops` — custom admin components, server components consuming the Local API - `api-design-ops` — REST/GraphQL surface design, pagination, versioning - `auth-ops` — auth collections, sessions/JWT, RBAC/ABAC patterns behind access control ### Key external resources - [What is Payload](https://payloadcms.com/docs/getting-started/what-is-payload) - [Collections](https://payloadcms.com/docs/configuration/collections) · [Fields](https://payloadcms.com/docs/fields/overview) - [Access control](https://payloadcms.com/docs/access-control/overview) - [Hooks](https://payloadcms.com/docs/hooks/overview) - [Local API](https://payloadcms.com/docs/local-api/overview) - [Database](https://payloadcms.com/docs/database/overview) · [Storage adapters](https://payloadcms.com/docs/upload/storage-adapters) - [Multi-tenant plugin](https://payloadcms.com/docs/plugins/multi-tenant)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.