Claude Code for Payload CMS: Headless CMS with TypeScript — Claude Skills 360 Blog
Blog / Backend / Claude Code for Payload CMS: Headless CMS with TypeScript
Backend

Claude Code for Payload CMS: Headless CMS with TypeScript

Published: February 20, 2027
Read time: 8 min read
By: Claude Skills 360

Payload CMS is a TypeScript-first headless CMS — collections, globals, and fields are defined in TypeScript config files that auto-generate REST endpoints, GraphQL schemas, and the admin UI. buildConfig({ collections, globals, db }) defines the full CMS setup. Collection documents are queried server-side with the local API: payload.find({ collection: "posts", where: { status: { equals: "published" } } }). Access control functions constrain reads and writes per user role. Hooks run before and after operations — beforeChange validates, afterChange triggers side effects. Custom fields, components, and admin UI plugins extend the default interface. PostgreSQL and MongoDB dialects are configured with the @payloadcms/db-postgres or @payloadcms/db-mongodb packages. Claude Code generates Payload collection definitions, field schemas, access control functions, hook implementations, and the Next.js App Router integration for full-stack content applications.

CLAUDE.md for Payload CMS

## Payload CMS Stack
- Version: payload >= 3.0, @payloadcms/next >= 3.0
- DB: @payloadcms/db-postgres (pg) or @payloadcms/db-mongodb
- Collections: CollectionConfig with fields, hooks, access, admin
- Fields: text, number, email, select, richText, relationship, array, blocks
- Access: access: { read: isAuthenticated, create: isAdmin } — function returning boolean
- Hooks: beforeChange / afterChange / afterRead — async functions with { data, req, doc }
- Local API: payload.find/findByID/create/update/delete — server-side, bypasses HTTP
- Next.js: @payloadcms/next provides RootLayout with PayloadProvider

Payload Config

// payload.config.ts — root configuration
import { buildConfig } from "payload"
import { postgresAdapter } from "@payloadcms/db-postgres"
import { lexicalEditor } from "@payloadcms/richtext-lexical"
import { Posts } from "./collections/Posts"
import { Orders } from "./collections/Orders"
import { Products } from "./collections/Products"
import { Users } from "./collections/Users"
import { SiteSettings } from "./globals/SiteSettings"

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET!,
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL!,

  admin: {
    user: Users.slug,
    meta: {
      titleSuffix: "— MyStore Admin",
      favicon: "/favicon.ico",
      ogImage: "/og-image.png",
    },
  },

  collections: [Posts, Orders, Products, Users],
  globals: [SiteSettings],

  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URL! },
  }),

  editor: lexicalEditor({}),

  cors: [process.env.NEXT_PUBLIC_SERVER_URL!],
  csrf: [process.env.NEXT_PUBLIC_SERVER_URL!],

  typescript: {
    outputFile: "./types/payload-types.ts",
  },
})

Collections

// collections/Posts.ts — blog post collection
import type { CollectionConfig } from "payload"
import { isAdmin, isAdminOrEditor, isPublished } from "../access"

export const Posts: CollectionConfig = {
  slug: "posts",
  admin: {
    useAsTitle: "title",
    defaultColumns: ["title", "status", "author", "publishedAt"],
    group: "Content",
  },

  access: {
    read: ({ req: { user }, data }) => {
      // Published posts: public; drafts: editors only
      if (user?.role === "admin" || user?.role === "editor") return true
      return { status: { equals: "published" } }
    },
    create: isAdminOrEditor,
    update: isAdminOrEditor,
    delete: isAdmin,
  },

  hooks: {
    beforeChange: [
      async ({ data, operation, req }) => {
        if (operation === "create") {
          data.author = req.user?.id
        }
        if (data.status === "published" && !data.publishedAt) {
          data.publishedAt = new Date().toISOString()
        }
        return data
      },
    ],
    afterChange: [
      async ({ doc, operation, req }) => {
        if (operation === "create" || doc.status === "published") {
          // Revalidate Next.js cache
          await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/revalidate`, {
            method: "POST",
            headers: { "x-revalidate-secret": process.env.REVALIDATE_SECRET! },
            body: JSON.stringify({ tag: "posts" }),
          }).catch(() => {})
        }
      },
    ],
  },

  fields: [
    {
      name: "title",
      type: "text",
      required: true,
      minLength: 3,
      maxLength: 140,
    },
    {
      name: "slug",
      type: "text",
      unique: true,
      admin: { position: "sidebar" },
      hooks: {
        beforeValidate: [
          ({ siblingData, value }) =>
            value ?? siblingData?.title?.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, ""),
        ],
      },
    },
    {
      name: "status",
      type: "select",
      options: [
        { label: "Draft", value: "draft" },
        { label: "Published", value: "published" },
        { label: "Archived", value: "archived" },
      ],
      defaultValue: "draft",
      admin: { position: "sidebar" },
    },
    {
      name: "author",
      type: "relationship",
      relationTo: "users",
      admin: { position: "sidebar" },
    },
    {
      name: "publishedAt",
      type: "date",
      admin: { position: "sidebar", date: { pickerAppearance: "dayAndTime" } },
    },
    {
      name: "excerpt",
      type: "textarea",
      maxLength: 280,
    },
    {
      name: "content",
      type: "richText",
    },
    {
      name: "tags",
      type: "array",
      fields: [
        { name: "tag", type: "text", required: true },
      ],
    },
    {
      name: "featuredImage",
      type: "upload",
      relationTo: "media",
    },
    {
      name: "seo",
      type: "group",
      fields: [
        { name: "title", type: "text", maxLength: 60 },
        { name: "description", type: "textarea", maxLength: 160 },
      ],
    },
  ],
}
// collections/Orders.ts — e-commerce order collection
import type { CollectionConfig } from "payload"

export const Orders: CollectionConfig = {
  slug: "orders",
  admin: {
    useAsTitle: "id",
    defaultColumns: ["id", "customer", "status", "totalCents", "createdAt"],
    group: "Commerce",
  },

  access: {
    read: ({ req: { user } }) => {
      if (!user) return false
      if (user.role === "admin") return true
      // Customers can only read their own orders
      return { customer: { equals: user.id } }
    },
    create: ({ req: { user } }) => !!user,
    update: ({ req: { user } }) => user?.role === "admin",
    delete: ({ req: { user } }) => user?.role === "admin",
  },

  fields: [
    {
      name: "customer",
      type: "relationship",
      relationTo: "users",
      required: true,
    },
    {
      name: "status",
      type: "select",
      options: ["pending", "processing", "shipped", "delivered", "cancelled"].map(v => ({
        label: v.charAt(0).toUpperCase() + v.slice(1),
        value: v,
      })),
      defaultValue: "pending",
      required: true,
    },
    {
      name: "items",
      type: "array",
      required: true,
      fields: [
        { name: "product", type: "relationship", relationTo: "products", required: true },
        { name: "name", type: "text", required: true },
        { name: "quantity", type: "number", required: true, min: 1 },
        { name: "priceCents", type: "number", required: true, min: 0 },
      ],
    },
    {
      name: "totalCents",
      type: "number",
      required: true,
      min: 0,
      admin: { readOnly: true },
    },
    {
      name: "stripePaymentIntentId",
      type: "text",
      admin: { readOnly: true, position: "sidebar" },
    },
    {
      name: "shippingAddress",
      type: "group",
      fields: [
        { name: "line1", type: "text" },
        { name: "city", type: "text" },
        { name: "country", type: "text" },
        { name: "postalCode", type: "text" },
      ],
    },
  ],
}

Global

// globals/SiteSettings.ts — singleton global config
import type { GlobalConfig } from "payload"

export const SiteSettings: GlobalConfig = {
  slug: "site-settings",
  access: {
    read: () => true,
    update: ({ req: { user } }) => user?.role === "admin",
  },
  fields: [
    { name: "siteName", type: "text", required: true },
    { name: "siteDescription", type: "textarea" },
    {
      name: "nav",
      type: "array",
      fields: [
        { name: "label", type: "text", required: true },
        { name: "url", type: "text", required: true },
      ],
    },
    {
      name: "socialLinks",
      type: "group",
      fields: [
        { name: "twitter", type: "text" },
        { name: "github", type: "text" },
      ],
    },
  ],
}

Local API Queries

// lib/payload-queries.ts — server-side local API
import { getPayload } from "payload"
import config from "@/payload.config"

// Cache payload instance across requests (Next.js)
let payloadInstance: Awaited<ReturnType<typeof getPayload>> | null = null

async function getPayloadClient() {
  if (!payloadInstance) {
    payloadInstance = await getPayload({ config })
  }
  return payloadInstance
}

export async function getPublishedPosts({
  limit = 10,
  page = 1,
  tag,
}: {
  limit?: number
  page?: number
  tag?: string
} = {}) {
  const payload = await getPayloadClient()

  const where = {
    status: { equals: "published" },
    ...(tag ? { "tags.tag": { equals: tag } } : {}),
  }

  return payload.find({
    collection: "posts",
    where,
    limit,
    page,
    sort: "-publishedAt",
    depth: 1,  // Populate relationships one level deep
  })
}

export async function getPostBySlug(slug: string) {
  const payload = await getPayloadClient()

  const { docs } = await payload.find({
    collection: "posts",
    where: { slug: { equals: slug }, status: { equals: "published" } },
    limit: 1,
    depth: 2,
  })

  return docs[0] ?? null
}

export async function getCustomerOrders(customerId: string) {
  const payload = await getPayloadClient()

  return payload.find({
    collection: "orders",
    where: { customer: { equals: customerId } },
    sort: "-createdAt",
    depth: 1,
    overrideAccess: false,  // Respect access control
  })
}

Next.js Integration

// app/(payload)/layout.tsx — Payload admin layout
import "@payloadcms/next/css"
import { RootLayout } from "@payloadcms/next/layouts"
import config from "@payload-config"

export default RootLayout({ config })
// app/(app)/blog/[slug]/page.tsx — Next.js page using local API
import { getPostBySlug, getPublishedPosts } from "@/lib/payload-queries"
import { notFound } from "next/navigation"

export async function generateStaticParams() {
  const { docs } = await getPublishedPosts({ limit: 100 })
  return docs.map(post => ({ slug: post.slug }))
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug)
  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.excerpt}</p>
      {/* Render rich text content */}
    </article>
  )
}

For the Sanity Studio alternative that provides a more flexible content studio with GROQ query language and real-time collaboration when content editors need advanced customization beyond Payload’s admin UI, see the Sanity guide for schema and GROQ patterns. For the Keystatic CMS alternative that stores content as files in git rather than a database — better for documentation sites and developer-first content workflows, the Astro DB guide covers file-based content patterns. The Claude Skills 360 bundle includes Payload CMS skill sets covering collections, access control, and Next.js integration. Start with the free tier to try Payload collection generation.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free