payload

Use when working with Payload projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.

Install
npx skills add 'https://github.com/payloadcms/payload/tree/main/packages/payload/skills/payload'
Download bundle ↓
main · 9a2cdcbScanned 2026-09-17

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md

Payload Collections Reference

Complete reference for collection configurations and patterns.

Basic Collection

import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  labels: {
    singular: 'Post',
    plural: 'Posts',
  },
  admin: {
    useAsTitle: 'title',
    // _status comes from versions.drafts below — no custom status field needed
    defaultColumns: ['title', 'author', '_status', 'createdAt'],
    group: 'Content', // Organize in admin sidebar
    description: 'Blog posts and articles',
    listSearchableFields: ['title', 'slug'],
  },
  // Enable drafts by default — auto-injects the _status field (draft/published/changed)
  versions: {
    drafts: true,
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
      index: true,
    },
    { name: 'slug', type: 'slug', useAsSlug: 'title' }, // unique + indexed, sidebar position — don't hand-roll a slug text field
  ],
  defaultSort: '-createdAt',
  timestamps: true,
}

Don't add a custom status select for publish state — enabling versions: { drafts: true } injects a managed _status field (draft / published / changed) that the admin UI and Draft Preview already understand. Use it in defaultColumns and access control directly.

useAsTitle

Set admin.useAsTitle to a stored top-level field. Do not use a computed field configured with virtual: true: it is not queryable, and Payload rejects it as useAsTitle.

A relationship-path virtual field is supported when the title must come from a related document:

export const Articles: CollectionConfig = {
  slug: 'articles',
  admin: {
    useAsTitle: 'authorName',
  },
  fields: [
    {
      name: 'author',
      type: 'relationship',
      relationTo: 'authors',
    },
    {
      name: 'authorName',
      type: 'text',
      virtual: 'author.name',
    },
  ],
}

This string-path form is queryable. It is different from a computed virtual: true field populated by an afterRead hook.

Auth Collection

export const Users: CollectionConfig = {
  slug: 'users',
  auth: {
    tokenExpiration: 7200, // 2 hours
    verify: true,
    maxLoginAttempts: 5,
    lockTime: 600000, // 10 minutes
    useAPIKey: true,
  },
  admin: {
    useAsTitle: 'email',
  },
  fields: [
    {
      name: 'roles',
      type: 'select',
      hasMany: true,
      options: ['admin', 'editor', 'user'],
      required: true,
      defaultValue: ['user'],
      saveToJWT: true,
    },
    {
      name: 'name',
      type: 'text',
      required: true,
    },
  ],
}

Upload Collection

export const Media: CollectionConfig = {
  slug: 'media',
  upload: {
    staticDir: 'media',
    mimeTypes: ['image/*'],
    imageSizes: [
      {
        name: 'thumbnail',
        width: 400,
        height: 300,
        position: 'centre',
      },
      {
        name: 'card',
        width: 768,
        height: 1024,
      },
    ],
    adminThumbnail: 'thumbnail',
    focalPoint: true,
    crop: true,
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: 'alt',
      type: 'text',
      required: true,
    },
    {
      name: 'caption',
      type: 'text',
      localized: true,
    },
  ],
}

Live Preview

Enable real-time content preview during editing.

import type { CollectionConfig } from 'payload'

const generatePreviewPath = ({
  slug,
  collection,
  req,
}: {
  slug: string
  collection: string
  req: any
}) => {
  const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL
  return `${baseUrl}/api/preview?slug=${slug}&collection=${collection}`
}

export const Pages: CollectionConfig = {
  slug: 'pages',
  admin: {
    useAsTitle: 'title',
    // Live preview during editing
    livePreview: {
      url: ({ data, req }) =>
        generatePreviewPath({
          slug: data?.slug as string,
          collection: 'pages',
          req,
        }),
    },
    // Static preview button
    preview: (data, { req }) =>
      generatePreviewPath({
        slug: data?.slug as string,
        collection: 'pages',
        req,
      }),
  },
  fields: [
    { name: 'title', type: 'text' },
    { name: 'slug', type: 'slug', useAsSlug: 'title' },
  ],
}

Versioning & Drafts

Payload maintains version history and supports draft/publish workflows.

import type { CollectionConfig } from 'payload'

// Basic versioning (audit log only)
export const Users: CollectionConfig = {
  slug: 'users',
  versions: true, // or { maxPerDoc: 100 }
  fields: [{ name: 'name', type: 'text' }],
}

// Drafts enabled (draft/publish workflow)
export const Posts: CollectionConfig = {
  slug: 'posts',
  versions: {
    drafts: true, // Enables _status field
    maxPerDoc: 50,
  },
  fields: [{ name: 'title', type: 'text' }],
}

// Full configuration with autosave and scheduled publish
export const Pages: CollectionConfig = {
  slug: 'pages',
  versions: {
    drafts: {
      autosave: true, // Auto-save while editing
      schedulePublish: true, // Schedule future publish/unpublish
      validate: false, // Don't validate drafts (default)
    },
    maxPerDoc: 100, // Keep last 100 versions (0 = unlimited)
  },
  fields: [{ name: 'title', type: 'text' }],
}

Draft API Usage

// Create draft
await payload.create({
  collection: 'posts',
  data: { title: 'Draft Post' },
  draft: true, // Saves as draft, skips required field validation
})

// Update as draft
await payload.update({
  collection: 'posts',
  id: '123',
  data: { title: 'Updated Draft' },
  draft: true,
})

// Read with drafts (returns newest draft if available)
const post = await payload.findByID({
  collection: 'posts',
  id: '123',
  draft: true, // Returns draft version if exists
})

// Query only published (REST API)
// GET /api/posts (returns only _status: 'published')

// Access control for drafts
export const Posts: CollectionConfig = {
  slug: 'posts',
  versions: { drafts: true },
  access: {
    read: ({ req: { user } }) => {
      // Public can only see published
      if (!user) return { _status: { equals: 'published' } }
      // Authenticated can see all
      return true
    },
  },
  fields: [{ name: 'title', type: 'text' }],
}

Document Status

The _status field is auto-injected when drafts are enabled:

  • draft - Never published
  • published - Published with no newer drafts
  • changed - Published but has newer unpublished drafts

Globals

Globals are single-instance documents (not collections).

import type { GlobalConfig } from 'payload'

export const Header: GlobalConfig = {
  slug: 'header',
  label: 'Header',
  admin: {
    group: 'Settings',
  },
  fields: [
    {
      name: 'logo',
      type: 'upload',
      relationTo: 'media',
      required: true,
    },
    {
      name: 'nav',
      type: 'array',
      maxRows: 8,
      fields: [
        {
          name: 'link',
          type: 'relationship',
          relationTo: 'pages',
        },
        {
          name: 'label',
          type: 'text',
        },
      ],
    },
  ],
}
Referenced from SKILL.md