Claude Skill

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

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

Full trust report

Download 0xdarkmatter-claude-mods-skills_payloadcms-ops-3dfaf0b.zip · 10 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/payloadcms-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git 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 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

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' + 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

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.

No comments yet.

Reviews (0)

No reviews yet.

Related