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.