Claude Code with Payload CMS: Type-Safe Content
Why Payload without CLAUDE.md ships a CMS that serves everything to everyone
Payload is a TypeScript-native headless CMS that defines content as code: collections and globals declared in config files, an admin UI generated from those definitions, a database schema migrated to match, and a fully typed API generated from the same source. The appeal is that the content model, the API, and the types are one thing rather than three that drift apart. You write a collection, Payload gives you the admin panel, the REST and GraphQL endpoints, a Local API for server-side calls, and a generated payload-types.ts that makes every query type-safe. The risk is that Payload's defaults favour getting started over locking down, and an assistant generating "a Payload collection" reproduces the open defaults instead of the closed ones a real application needs.
A Claude Code session that builds Payload collections without project rules produces a content model that works in the admin panel and returns data from the API, which is enough to look finished. What it skips is the access control that decides who can read and write each document. Payload collections without an explicit access config are readable and writable according to permissive defaults, so a collection Claude generates for internal data is served to anyone who hits the endpoint. It writes hooks that run on every operation without checking which operation triggered them, so a beforeChange hook meant for creates also fires on every update and corrupts data. It hand-writes types that duplicate the generated ones and drift the moment a field changes. It calls the REST API from server code where the Local API would skip the network and the auth round-trip. Each of these is a default Payload allows, and none of them shows up until the data the CMS was supposed to protect is sitting in a public response.
This guide covers the CLAUDE.md configuration that locks Claude Code into Payload patterns that protect their data: access control declared on every collection and closed by default, the generated types treated as the single source of truth, a hook model that checks its operation, the Local API used for server-side calls, and field-level access for the data that needs it. For the structured-content alternative with a hosted backend, Claude Code with Sanity covers the patterns for a hosted headless CMS. For the framework Payload runs inside, Claude Code with Next.js covers the app-level integration.
The Payload CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Payload integration it needs to declare: the version and database adapter, the access control policy, the type generation workflow, the hook conventions, the API boundary, and the hard rules that block the open-by-default mistakes Claude makes most often.
# Payload CMS rules
## Stack
- payload 3.x with the Next.js integration
- Database adapter: @payloadcms/db-postgres (or db-mongodb)
- Local API for all server-side data access
- payload-types.ts is GENERATED, never hand-edited
## Access control (MANDATORY)
- EVERY collection declares an access object: read, create, update, delete
- Default to CLOSED: return false unless a rule grants access
- NEVER omit the access config and rely on Payload defaults
- Field-level access on sensitive fields (e.g. role, internal notes)
- Admin-only collections gate read behind a role check
## Types (MANDATORY)
- Run payload generate:types after EVERY collection or field change
- Import types from payload-types.ts, NEVER hand-write a content type
- The generated type is the source of truth for shape
## Hooks (MANDATORY)
- Hooks check the operation argument (create vs update) before acting
- beforeChange / afterChange / beforeValidate used for their stated phase
- Hooks are pure where possible; side effects are explicit and logged
- NEVER assume a hook fires on only one operation
## API boundary
- Local API (payload.find, payload.create) for server-side code
- REST/GraphQL for the browser and external clients only
- NEVER call the REST API from server code that can use the Local API
## Hard rules
- NEVER ship a collection without an explicit access config
- NEVER default access to open; closed is the baseline
- NEVER hand-edit or hand-write a type that payload generates
- NEVER write a hook that ignores its operation argument
- NEVER expose a sensitive field without field-level access
- NEVER call REST from server code where the Local API works
- ALWAYS run generate:types after a schema change
- ALWAYS gate admin-only data behind a role check in access.read
Four rules here prevent the majority of the data-exposure bugs Claude generates without them.
The access control rule is the one that decides whether the CMS protects its data. Payload's access config on a collection is a set of functions that return true or false for each operation, and a collection with no access object falls back to defaults that are more open than most applications want. The rule requires an explicit access object on every collection with read, create, update, and delete functions, and it requires those functions to default to closed: return false unless a condition grants access. This inverts Payload's permissive default into a deny-by-default posture where data is exposed only when a rule explicitly allows it.
The generated types rule keeps the type layer honest. Payload generates payload-types.ts from the collection config, and that file is the accurate shape of every document. The temptation, especially for an assistant, is to hand-write a type Post = { ... } that looks right today and drifts the moment a field is added or renamed. The rule forbids hand-written content types entirely: import from payload-types.ts and regenerate after every schema change, so the types can never disagree with the database.
The hook operation rule prevents the data corruption that comes from a hook firing on the wrong operation. Payload hooks like beforeChange run on both creates and updates, and a hook that assumes it only runs on create, for example by setting a field that should only be set once, will overwrite that field on every update. The rule requires every hook to inspect its operation argument and act only on the operations it is meant for.
Install and project structure
Install Payload with the database adapter and the bundler appropriate for the project. The Next.js integration is the common path.
npx create-payload-app@latest
# or into an existing Next.js app
npm install payload @payloadcms/next @payloadcms/db-postgres
The structure Payload expects keeps collections, globals, and access functions in their own modules so the config file stays readable and the access logic is testable in isolation.
src/
payload.config.ts , the root config, imports collections
collections/
Users.ts , auth collection with field-level access
Posts.ts , content collection with access config
Internal.ts , admin-only collection
access/
isAdmin.ts , reusable access functions
isAdminOrSelf.ts
hooks/
setPublishedAt.ts , a hook that checks its operation
payload-types.ts , GENERATED, do not edit
The access/ directory holds reusable access functions so the same rule, like "admin or the document owner", is defined once and imported into every collection that needs it. This keeps access logic consistent and makes it the kind of thing a reviewer can read in one place rather than re-deriving from each collection.
A collection with access closed by default
A production collection declares its access config explicitly and defaults to closed. Here is a content collection where published posts are public to read but only admins can write.
// src/collections/Posts.ts
import type { CollectionConfig } from "payload";
import { isAdmin } from "../access/isAdmin";
export const Posts: CollectionConfig = {
slug: "posts",
access: {
read: ({ req }) => {
// public can read published posts; admins read everything
if (isAdmin({ req })) return true;
return { _status: { equals: "published" } }; // query constraint, not just true
},
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{ name: "title", type: "text", required: true },
{ name: "slug", type: "text", required: true, unique: true },
{ name: "content", type: "richText" },
{
name: "internalNotes",
type: "textarea",
access: {
read: isAdmin, // field-level: only admins see this field
update: isAdmin,
},
},
],
versions: { drafts: true },
};
The access.read returns a query constraint for non-admins rather than a blanket true, so the public sees only documents where _status equals published and never an unpublished draft. The write operations are gated behind isAdmin. The internalNotes field carries its own field-level access, so even on a post a reader can see, that field is stripped from the response unless the requester is an admin. This is the layered access posture the CLAUDE.md rule produces: closed at the collection level, refined per operation, and tightened per field.
// src/access/isAdmin.ts
import type { Access } from "payload";
export const isAdmin: Access = ({ req }) => {
return req.user?.role === "admin";
};
The reusable function reads the authenticated user's role and returns a boolean. It returns false for an unauthenticated request because req.user is undefined, which is the closed-by-default behaviour the rule demands. Every collection that needs an admin gate imports this one function.
Hooks that check their operation
A hook that runs on beforeChange fires on both create and update. A hook that ignores this difference corrupts data on the operation it did not expect. The pattern is to branch on the operation argument.
// src/hooks/setPublishedAt.ts
import type { CollectionBeforeChangeHook } from "payload";
export const setPublishedAt: CollectionBeforeChangeHook = ({ data, operation, originalDoc }) => {
// Set publishedAt once, on the transition to published, never overwrite it.
if (data._status === "published" && originalDoc?._status !== "published") {
return { ...data, publishedAt: new Date().toISOString() };
}
return data;
};
The hook sets publishedAt only when a document transitions into the published state, detected by comparing the incoming _status against the originalDoc status. On every other change, including subsequent updates to an already-published post, it leaves the field alone. A hook that ignored the operation and set publishedAt on every beforeChange would reset the publish date every time an editor fixed a typo, which is the silent data bug the operation rule prevents. Wire it into the collection's hooks array.
// in Posts.ts
hooks: {
beforeChange: [setPublishedAt],
},
The Local API for server-side calls
Payload exposes two ways to read and write data: the REST and GraphQL APIs for clients, and the Local API for server-side code. The Local API skips the network and the HTTP auth round-trip, which makes it faster and lets server code run with explicit access control rather than an inferred user. The rule is to use the Local API in server code.
// src/server/getPublishedPosts.ts
import { getPayload } from "payload";
import config from "../payload.config";
import type { Post } from "../../payload-types";
export async function getPublishedPosts(): Promise<Post[]> {
const payload = await getPayload({ config });
const result = await payload.find({
collection: "posts",
where: { _status: { equals: "published" } },
limit: 20,
});
return result.docs;
}
The function gets a Payload instance and calls payload.find directly, with no HTTP request and no token. The return type is the generated Post type imported from payload-types.ts, so the shape is guaranteed to match the collection. A version of this that called the REST endpoint from the server would add a network hop, an auth round-trip, and a hand-written response type, all of which the Local API and the generated types make unnecessary. This is the API boundary the CLAUDE.md rule draws.
Keeping types in sync
The generated types are only the source of truth if they are regenerated after every change. Wire the generation into the workflow so a schema change always updates the types.
// package.json
{
"scripts": {
"generate:types": "payload generate:types",
"predev": "payload generate:types",
"prebuild": "payload generate:types"
}
}
The predev and prebuild scripts run type generation before every dev session and every build, so the types can never lag the schema in a running app or a deployed bundle. A CI check that runs generate:types and fails if the working tree changes catches the case where someone changed a collection and forgot to regenerate. This automation is what makes the generated-types rule enforceable rather than aspirational.
Permission hooks for Payload operations
Payload has operations that can wipe collections or run destructive migrations. Permission hooks in .claude/settings.local.json keep an automated session from running them.
{
"permissions": {
"allow": [
"Bash(payload generate:types*)",
"Bash(payload generate:importmap*)",
"Bash(npm run generate:types*)"
],
"deny": [
"Bash(payload migrate:fresh*)",
"Bash(payload migrate:down*)",
"Bash(payload migrate:reset*)",
"Bash(*DROP TABLE*)",
"Bash(*db.dropDatabase*)"
]
}
}
The allow list permits the safe generation commands Claude needs: regenerating types and the import map. The deny list blocks the destructive migration commands, migrate:fresh and migrate:reset wipe and rebuild the schema, migrate:down rolls back, and the raw drop commands destroy data directly. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can keep types in sync without being able to wipe the content database.
Common Claude Code mistakes with Payload
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Collection with no access config
Claude generates: a collection with fields but no access object.
Correct pattern: explicit access with read, create, update, delete, defaulting to closed.
2. Access defaulting to open
Claude generates: read: () => true on an internal collection.
Correct pattern: return a query constraint or a role check, never a blanket true for non-public data.
3. Hand-written content type
Claude generates: type Post = { title: string; ... } duplicating the generated type.
Correct pattern: import Post from payload-types.ts and regenerate after schema changes.
4. Hook that ignores its operation
Claude generates: a beforeChange hook that sets a create-only field on every change.
Correct pattern: branch on the operation argument and act only on the intended operation.
5. REST API called from server code
Claude generates: fetch('/api/posts') inside a server function.
Correct pattern: payload.find({ collection: 'posts' }) via the Local API.
6. Sensitive field with no field-level access
Claude generates: a role or internalNotes field readable by anyone who can read the document.
Correct pattern: a field-level access.read gating it behind a role check.
Add each pattern to CLAUDE.md with the corrected form. Combined with CLAUDE.md examples for the general structure, the result is Payload code Claude generates correctly the first time.
Testing access control
The tests that matter for Payload prove that access control closes the right doors: that an unauthenticated request cannot read internal data, that a non-admin cannot write, that a field-level rule strips a sensitive field. A test that only reads public data proves nothing about the access layer.
import { describe, it, expect } from "vitest";
import { getPayload } from "payload";
import config from "../src/payload.config";
describe("posts access control", () => {
it("hides unpublished posts from an anonymous request", async () => {
const payload = await getPayload({ config });
await payload.create({ collection: "posts", data: { title: "Draft", _status: "draft", slug: "d" } });
const result = await payload.find({
collection: "posts",
overrideAccess: false, // enforce access as an anonymous user
user: null,
});
expect(result.docs.find((d) => d.title === "Draft")).toBeUndefined();
});
it("strips internalNotes from a non-admin read", async () => {
const payload = await getPayload({ config });
const result = await payload.find({
collection: "posts",
overrideAccess: false,
user: { role: "editor" } as never,
});
expect(result.docs[0]).not.toHaveProperty("internalNotes");
});
});
The first test creates a draft and confirms an anonymous query with overrideAccess: false does not return it, proving the read constraint works. The second confirms the field-level access strips internalNotes for a non-admin. The overrideAccess: false flag is the key: it tells the Local API to enforce access control as the given user rather than running with full privileges, which is what makes these tests exercise the real access layer. For the broader testing setup, Claude Code with Vitest covers the configuration.
Building Payload that protects its data
The Payload CLAUDE.md in this guide produces a CMS where every collection declares access control that defaults to closed, the generated types are the single source of truth and stay in sync, hooks check their operation before acting, server code uses the Local API, and sensitive fields carry field-level access. The result is a content layer that exposes data only when a rule allows it, instead of one that serves everything because the defaults were never tightened.
The principle is the same as any content system with Claude Code. Payload without a CLAUDE.md produces collections that work in the admin panel and conceal the open defaults that leak data through the API. The CLAUDE.md is what makes closed-by-default access and in-sync types the default for Claude.
For the structured-content alternative with a hosted backend, Claude Code with Sanity covers a hosted headless CMS. For the framework Payload runs inside, Claude Code with Next.js covers the app-level integration. For the database under a Payload Postgres install, Claude Code with PostgreSQL covers the schema and query patterns.
Get Claudify. Ship a Payload CMS where access control is closed by default and types never drift.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify