Agent Skills: Payload CMS Development

>

UncategorizedID: connorads/dotfiles/payload-cms

Install this agent skill to your local

pnpm dlx add-skill https://github.com/connorads/dotfiles/tree/HEAD/.agents/skills/payload-cms

Skill Files

Browse the full folder contents for payload-cms.

Download Skill

Loading file tree…

.agents/skills/payload-cms/SKILL.md

Skill Metadata

Name
payload-cms
Description
>

Payload CMS Development

Payload is a Next.js native CMS with TypeScript-first architecture. This skill transfers expert knowledge for building collections, hooks, access control, and queries the right way.

Mental Model

Think of Payload as three interconnected layers:

  1. Config Layer → Collections, globals, fields define your schema
  2. Hook Layer → Lifecycle events transform and validate data
  3. Access Layer → Functions control who can do what

Every operation flows through: Config → Access Check → Hook Chain → Database → Response Hooks

Quick Reference

| Task | Solution | Details | |------|----------|---------| | Auto-generate slugs | slugField() or beforeChange hook | [references/fields.md#slug-field] | | Restrict by user | Access control with query constraint | [references/access-control.md] | | Local API with auth | user + overrideAccess: false | [references/queries.md#local-api] | | Draft/publish | versions: { drafts: true } | [references/collections.md#drafts] | | Computed fields | virtual: true with afterRead hook | [references/fields.md#virtual] | | Conditional fields | admin.condition | [references/fields.md#conditional] | | Filter relationships | filterOptions on field | [references/fields.md#relationship] | | Prevent hook loops | req.context flag | [references/hooks.md#context] | | Transactions | Pass req to all operations | [references/hooks.md#transactions] | | Background jobs | Jobs queue with tasks | [references/advanced.md#jobs] |

Quick Start

npx create-payload-app@latest my-app
cd my-app
pnpm dev

Minimal Config

import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'

export default buildConfig({
  admin: { user: 'users' },
  collections: [Users, Media, Posts],
  editor: lexicalEditor(),
  secret: process.env.PAYLOAD_SECRET,
  typescript: { outputFile: 'payload-types.ts' },
  db: mongooseAdapter({ url: process.env.DATABASE_URL }),
})

Core Patterns

Collection Definition

import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'author', 'status', 'createdAt'],
  },
  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' },
    { name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft' },
  ],
  timestamps: true,
}

Hook Pattern (Auto-slug)

export const Posts: CollectionConfig = {
  slug: 'posts',
  hooks: {
    beforeChange: [
      async ({ data, operation }) => {
        if (operation === 'create' && data.title) {
          data.slug = data.title.toLowerCase().replace(/\s+/g, '-')
        }
        return data
      },
    ],
  },
  fields: [{ name: 'title', type: 'text', required: true }],
}

Access Control Pattern

import type { Access } from 'payload'

// Type-safe: admin-only access
export const adminOnly: Access = ({ req }) => {
  return req.user?.roles?.includes('admin') ?? false
}

// Row-level: users see only their own posts
export const ownPostsOnly: Access = ({ req }) => {
  if (!req.user) return false
  if (req.user.roles?.includes('admin')) return true
  return { author: { equals: req.user.id } }
}

Query Pattern

// Local API with access control
const posts = await payload.find({
  collection: 'posts',
  where: {
    status: { equals: 'published' },
    'author.name': { contains: 'john' },
  },
  depth: 2,
  limit: 10,
  sort: '-createdAt',
  user: req.user,
  overrideAccess: false, // CRITICAL: enforce permissions
})

Critical Security Rules

1. Local API Access Control

Default behavior bypasses ALL access control. This is the #1 security mistake.

// ❌ SECURITY BUG: Access control bypassed even with user
await payload.find({ collection: 'posts', user: someUser })

// ✅ SECURE: Explicitly enforce permissions
await payload.find({
  collection: 'posts',
  user: someUser,
  overrideAccess: false, // REQUIRED
})

Rule: Use overrideAccess: false for any operation acting on behalf of a user.

2. Transaction Integrity

Operations without req run in separate transactions.

// ❌ DATA CORRUPTION: Separate transaction
hooks: {
  afterChange: [async ({ doc, req }) => {
    await req.payload.create({
      collection: 'audit-log',
      data: { docId: doc.id },
      // Missing req - breaks atomicity!
    })
  }]
}

// ✅ ATOMIC: Same transaction
hooks: {
  afterChange: [async ({ doc, req }) => {
    await req.payload.create({
      collection: 'audit-log',
      data: { docId: doc.id },
      req, // Maintains transaction
    })
  }]
}

Rule: Always pass req to nested operations in hooks.

3. Infinite Hook Loops

Hooks triggering themselves create infinite loops.

// ❌ INFINITE LOOP
hooks: {
  afterChange: [async ({ doc, req }) => {
    await req.payload.update({
      collection: 'posts',
      id: doc.id,
      data: { views: doc.views + 1 },
      req,
    }) // Triggers afterChange again!
  }]
}

// ✅ SAFE: Context flag breaks the loop
hooks: {
  afterChange: [async ({ doc, req, context }) => {
    if (context.skipViewUpdate) return
    await req.payload.update({
      collection: 'posts',
      id: doc.id,
      data: { views: doc.views + 1 },
      req,
      context: { skipViewUpdate: true },
    })
  }]
}

Project Structure

src/
├── app/
│   ├── (frontend)/page.tsx
│   └── (payload)/admin/[[...segments]]/page.tsx
├── collections/
│   ├── Posts.ts
│   ├── Media.ts
│   └── Users.ts
├── globals/Header.ts
├── hooks/slugify.ts
└── payload.config.ts

Type Generation

Generate types after schema changes:

// payload.config.ts
export default buildConfig({
  typescript: { outputFile: 'payload-types.ts' },
})

// Usage
import type { Post, User } from '@/payload-types'

Getting Payload Instance

// In API routes
import { getPayload } from 'payload'
import config from '@payload-config'

export async function GET() {
  const payload = await getPayload({ config })
  const posts = await payload.find({ collection: 'posts' })
  return Response.json(posts)
}

// In Server Components
export default async function Page() {
  const payload = await getPayload({ config })
  const { docs } = await payload.find({ collection: 'posts' })
  return <div>{docs.map(p => <h1 key={p.id}>{p.title}</h1>)}</div>
}

Common Field Types

// Text
{ name: 'title', type: 'text', required: true }

// Relationship
{ name: 'author', type: 'relationship', relationTo: 'users' }

// Rich text
{ name: 'content', type: 'richText' }

// Select
{ name: 'status', type: 'select', options: ['draft', 'published'] }

// Upload
{ name: 'image', type: 'upload', relationTo: 'media' }

// Array
{
  name: 'tags',
  type: 'array',
  fields: [{ name: 'tag', type: 'text' }],
}

// Blocks (polymorphic content)
{
  name: 'layout',
  type: 'blocks',
  blocks: [HeroBlock, ContentBlock, CTABlock],
}

Decision Framework

When choosing between approaches:

| Scenario | Approach | |----------|----------| | Data transformation before save | beforeChange hook | | Data transformation after read | afterRead hook | | Enforce business rules | Access control function | | Complex validation | validate function on field | | Computed display value | Virtual field with afterRead | | Related docs list | join field type | | Side effects (email, webhook) | afterChange hook with context guard | | Database-level constraint | Field with unique: true or index: true |

Quality Checks

Good Payload code:

  • [ ] All Local API calls with user context use overrideAccess: false
  • [ ] All hook operations pass req for transaction integrity
  • [ ] Recursive hooks use context flags
  • [ ] Types generated and imported from payload-types.ts
  • [ ] Access control functions are typed with Access type
  • [ ] Collections have meaningful admin.useAsTitle set

Reference Documentation

For detailed patterns, see:

Resources

  • Docs: https://payloadcms.com/docs
  • LLM Context: https://payloadcms.com/llms-full.txt
  • GitHub: https://github.com/payloadcms/payload
  • Templates: https://github.com/payloadcms/payload/tree/main/templates