Payload Application Development

SkillMonitoring & ops

Payload CMS application development (collections, fields, hooks, access control, Local/REST/GraphQL queries, adapters, plugins). Vendored from payloadcms/skills. Use when editing payload.config.ts, Payload collections, admin, or debugging validation, security, relationships, transactions, or hooks in this repo.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Payload Application Development skill

What this skill tells your AI

The instructions your AI receives, as published by asymmetric-al/core in .agents/skills/payloadcms-payload/SKILL.md and read by ahel’s review.

Payload is a Next.js native CMS with TypeScript-first architecture, providing admin panel, database management, REST/GraphQL APIs, authentication, and file storage.

Triggers

Use this skill when work touches Payload CMS application behavior in this repo, including:

  • apps/admin/payload.config.ts, Payload collections, globals, fields, hooks, access control, admin routes, Local API usage, REST/GraphQL access, adapters, or plugins.
  • Web Studio or CMS work under apps/admin/app/(payload)/**, apps/admin/src/cms/**, apps/admin/src/cms-ui/**, and related tests or docs.
  • Payload validation, relationships, transactions, hook recursion, access-control behavior, generated types, migrations, import maps, or storage/email adapter behavior.

Do not use when

  • The task is only Supabase schema, RLS, Auth, Storage, or Edge Functions work; use the Supabase skills and nested Supabase instructions instead.
  • The task is only Next.js route-handler or shared API boundary work without Payload-specific behavior; use docs/guides/architecture/data-access-boundary.md and backend rules first.
  • The task is migrating content models from another CMS into Payload; use docs/ai/skills/payloadcms-cms-migration/SKILL.md.

This repository (Asymmetric-al/core)

Canonical vendored source: payloadcms/skills (skills/payload/). Maintainer refresh: docs/ai/skills/payloadcms-payload/references/upstream.md.

Precedence: OpenSpec and docs/ai/rules/backend.md control durable behavior and security. Supabase schema, RLS, and Auth follow docs/ai/skills/supabase/SKILL.md and supabase/AGENTS.md. Prefer the repo's installed Payload version and vendor/payload-upstream/ when upstream examples differ from local APIs.

Workflow

  1. Read This repository and apply the precedence rules before using generic upstream examples.
  2. Open the local Payload entry points, starting with apps/admin/payload.config.ts and the relevant files under apps/admin/src/cms/** or apps/admin/src/cms-ui/**.
  3. Use the matching topic reference under reference/ for the specific Payload pattern: collections, fields, hooks, access, queries, adapters, endpoints, or plugins.
  4. Prefer repo conventions over generic examples for auth, storage, migrations, import maps, and generated types.
  5. Run focused verification for changed Payload behavior.

Quick Reference

TaskSolutionDetails
Auto-generate slugsslugField()FIELDS.md#slug-field-helper
Restrict content by userAccess control with queryACCESS-CONTROL.md#row-level-security-with-complex-queries
Local API user opsuser + overrideAccess: falseQUERIES.md#access-control-in-local-api
Draft/publish workflowversions: { drafts: true }COLLECTIONS.md#versioning--drafts
Computed fieldsvirtual: true with afterReadFIELDS.md#virtual-fields
Conditional fieldsadmin.conditionFIELDS.md#conditional-fields
Custom field validationvalidate functionFIELDS.md#text-field
Filter relationship listfilterOptions on fieldFIELDS.md#relationship
Select specific fieldsselect parameterQUERIES.md#local-api
Auto-set author/datesbeforeChange hookHOOKS.md#collection-hooks
Prevent hook loopsreq.context checkHOOKS.md#hook-context
Cascading deletesbeforeDelete hookHOOKS.md#collection-hooks
Geospatial queriespoint field with near/withinFIELDS.md#point-geolocation
Reverse relationshipsjoin field typeFIELDS.md#join-fields
Next.js revalidationContext control in afterChangeHOOKS.md#nextjs-revalidation-with-context-control
Query by relationshipNested property syntaxQUERIES.md#nested-properties
Complex queriesAND/OR logicQUERIES.md#andor-logic
TransactionsPass req to operationsADAPTERS.md#threading-req-through-operations
Background jobsJobs queue with tasksADVANCED.md#jobs-queue
Custom API routesCollection custom endpointsADVANCED.md#custom-endpoints
Cloud storageStorage adapter pluginsADAPTERS.md#storage-adapters
Multi-languagelocalization config + localized: trueADVANCED.md#localization
Create plugin(options) => (config) => ConfigPLUGIN-DEVELOPMENT.md#plugin-architecture
Plugin package setupPackage structure with SWCPLUGIN-DEVELOPMENT.md#plugin-package-structure
Add fields to collectionMap collections, spread fieldsPLUGIN-DEVELOPMENT.md#adding-fields-to-collections
Plugin hooksPreserve existing hooks in arrayPLUGIN-DEVELOPMENT.md#adding-hooks
Check field typeType guard functionsFIELD-TYPE-GUARDS.md

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";
import path from "path";
import { fileURLToPath } from "url";

const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);

export default buildConfig({
  admin: {
    user: "users",
    importMap: {
      baseDir: path.resolve(dirname),
    },
  },
  collections: [Users, Media],
  editor: lexicalEditor(),
  secret: process.env.PAYLOAD_SECRET,
  typescript: {
    outputFile: path.resolve(dirname, "payload-types.ts"),
  },
  db: mongooseAdapter({
    url: process.env.DATABASE_URL,
  }),
});

Essential Patterns

Basic Collection

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" },
  ],
  timestamps: true,
};

For more collection patterns (auth, upload, drafts, live preview), see COLLECTIONS.md.

Common Fields

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

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

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

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

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

For all field types (array, blocks, point, join, virtual, conditional, etc.), see FIELDS.md.

Hook Example

export const Posts: CollectionConfig = {
  slug: "posts",
  hooks: {
    beforeChange: [
      async ({ data, operation }) => {
        if (operation === "create") {
          data.slug = slugify(data.title);
        }
        return data;
      },
    ],
  },
  fields: [{ name: "title", type: "text" }],
};

For all hook patterns, see HOOKS.md. For access control, see ACCESS-CONTROL.md.

Access Control with Type Safety

import type { Access } from "payload";
import type { User } from "@/payload-types";

// Type-safe access control
export const adminOnly: Access = ({ req }) => {
  const user = req.user as User;
  return user?.roles?.includes("admin") || false;
};

// Row-level access control
export const ownPostsOnly: Access = ({ req }) => {
  const user = req.user as User;
  if (!user) return false;
  if (user.roles?.includes("admin")) return true;

  return {
    author: { equals: user.id },
  };
};

Query Example

// Local API
const posts = await payload.find({
  collection: "posts",
  where: {
    status: { equals: "published" },
    "author.name": { contains: "john" },
  },
  depth: 2,
  limit: 10,
  sort: "-createdAt",
});

// Query with populated relationships
const post = await payload.findByID({
  collection: "posts",
  id: "123",
  depth: 2, // Populates relationships (default is 2)
});
// Returns: { author: { id: "user123", name: "John" } }

// Without depth, relationships return IDs only
const post = await payload.findByID({
  collection: "posts",
  id: "123",
  depth: 0,
});
// Returns: { author: "user123" }

For all query operators and REST/GraphQL examples, see QUERIES.md.

Getting Payload Instance

// In API routes (Next.js)
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
import { getPayload } from 'payload'
import config from '@payload-config'

export default async function Page() {
  const payload = await getPayload({ config })
  const { docs } = await payload.find({ collection: 'posts' })

  return <div>{docs.map(post => <h1 key={post.id}>{post.title}</h1>)}</div>
}

Logger Usage

// ✅ Valid: single string
payload.logger.error("Something went wrong");

// ✅ Valid: object with msg and err
payload.logger.error({ msg: "Failed to process", err: error });

// ❌ Invalid: don't pass error as second argument
payload.logger.error("Failed to process", error);

// ❌ Invalid: use `err` not `error`, use `msg` not `message`
payload.logger.error({ message: "Failed", error: error });

Security Pitfalls

1. Local API Access Control (CRITICAL)

By default, Local API operations bypass ALL access control, even when passing a user.

// ❌ SECURITY BUG: Passes user but ignores their permissions
await payload.find({
  collection: "posts",
  user: someUser, // Access control is BYPASSED!
});

// ✅ SECURE: Actually enforces the user's permissions
await payload.find({
  collection: "posts",
  user: someUser,
  overrideAccess: false, // REQUIRED for access control
});

When to use each:

  • overrideAccess: true (default) - Server-side operations you trust (cron jobs, system tasks)
  • overrideAccess: false - When operating on behalf of a user (API routes, webhooks)

See QUERIES.md#access-control-in-local-api.

2. Transaction Failures in Hooks

Nested operations in hooks without req break transaction atomicity.

// ❌ DATA CORRUPTION RISK: Separate transaction
hooks: {
  afterChange: [
    async ({ doc, req }) => {
      await req.payload.create({
        collection: "audit-log",
        data: { docId: doc.id },
        // Missing req - runs in separate transaction!
      });
    },
  ];
}

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

See ADAPTERS.md#threading-req-through-operations.

3. Infinite Hook Loops

Hooks triggering operations that trigger the same hooks 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: Use context flag
hooks: {
  afterChange: [
    async ({ doc, req, context }) => {
      if (context.skipHooks) return;

      await req.payload.update({
        collection: "posts",
        id: doc.id,
        data: { views: doc.views + 1 },
        context: { skipHooks: true },
        req,
      });
    },
  ];
}

See HOOKS.md#context.

Project Structure

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

Type Generation

// payload.config.ts
export default buildConfig({
  typescript: {
    outputFile: path.resolve(dirname, "payload-types.ts"),
  },
  // ...
});

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

Reference Documentation

  • FIELDS.md - All field types, validation, admin options
  • FIELD-TYPE-GUARDS.md - Type guards for runtime field type checking and narrowing
  • COLLECTIONS.md - Collection configs, auth, upload, drafts, live preview
  • HOOKS.md - Collection hooks, field hooks, context patterns
  • ACCESS-CONTROL.md - Collection, field, global access control, RBAC, multi-tenant
  • ACCESS-CONTROL-ADVANCED.md - Context-aware, time-based, subscription-based access, factory functions, templates
  • QUERIES.md - Query operators, Local/REST/GraphQL APIs
  • ENDPOINTS.md - Custom API endpoints: authentication, helpers, request/response patterns
  • ADAPTERS.md - Database, storage, email adapters, transactions
  • ADVANCED.md - Authentication, jobs, endpoints, components, plugins, localization
  • PLUGIN-DEVELOPMENT.md - Plugin architecture, monorepo structure, patterns, best practices

Resources

Signals

GitHub stars
391
Forks
7
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
payloadcms-payload
Source
github.com/asymmetric-al/core