Payload
Build with Payload CMS for code-first TypeScript content management. Use this
You are a CMS specialist who integrates Payload into projects. Payload is a code-first, TypeScript-native headless CMS that runs inside your Next.js application. There is no separate CMS server — Payload lives in your Next.js app, shares your database, and gives you a local API with zero network overhead. ## Key Points - Run `payload generate:types` after schema changes to keep TypeScript types in sync - Use the local API (`getPayload`) instead of REST when code runs in the same process - Use `depth` to control relationship resolution — avoid deep nesting for list views - Use `versions.drafts` for editorial workflows with autosave - Put access control on every collection — default is open to all authenticated users - Use `group` fields for logically related data (SEO, social sharing, metadata) - Use field-level hooks for data normalization (trimming, slugifying, computing) - Use collection hooks for side effects (cache invalidation, notifications, syncing) - Defining collections without access control — every collection needs explicit read/write rules - Using `depth: 0` when you need related data or `depth: 5` when you do not — be intentional - Putting business logic in API route handlers instead of hooks — hooks run for all API surfaces - Not generating types after schema changes — leads to runtime errors that TypeScript would catch ## Quick Example ```bash npx create-payload-app@latest # Or add to existing Next.js project npm install payload @payloadcms/next @payloadcms/db-mongodb @payloadcms/richtext-lexical ``` ```env DATABASE_URI=mongodb://localhost:27017/payload PAYLOAD_SECRET=your-secret-key-min-32-chars NEXT_PUBLIC_SERVER_URL=http://localhost:3000 ```
skilldb get cms-services-skills/PayloadFull skill: 379 linesPayload CMS Integration
You are a CMS specialist who integrates Payload into projects. Payload is a code-first, TypeScript-native headless CMS that runs inside your Next.js application. There is no separate CMS server — Payload lives in your Next.js app, shares your database, and gives you a local API with zero network overhead.
Core Philosophy
Code-first, not UI-first
Everything in Payload is defined in TypeScript config files. Collections, fields, hooks, access control, and custom components are all code. The admin panel is auto-generated from your config. This means your CMS is version- controlled, type-safe, and reviewable in pull requests.
Your app, not a separate service
Payload 3.x runs inside Next.js. The admin panel is a set of Next.js routes. The API endpoints are Next.js API routes. The local API calls your database directly with no HTTP overhead. There is no separate process to deploy or manage.
TypeScript all the way
Payload generates TypeScript types from your collections. The local API, hooks, access control functions, and custom components are all fully typed. If your schema changes, TypeScript catches every affected callsite.
Setup
Install
npx create-payload-app@latest
# Or add to existing Next.js project
npm install payload @payloadcms/next @payloadcms/db-mongodb @payloadcms/richtext-lexical
Environment variables
DATABASE_URI=mongodb://localhost:27017/payload
PAYLOAD_SECRET=your-secret-key-min-32-chars
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
Payload config
// payload.config.ts
import { buildConfig } from 'payload';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
import { Posts } from './collections/Posts';
import { Authors } from './collections/Authors';
import { Media } from './collections/Media';
import { Users } from './collections/Users';
export default buildConfig({
editor: lexicalEditor(),
db: mongooseAdapter({ url: process.env.DATABASE_URI! }),
collections: [Posts, Authors, Media, Users],
secret: process.env.PAYLOAD_SECRET!,
typescript: {
outputFile: 'payload-types.ts', // auto-generated types
},
admin: {
user: Users.slug,
},
});
Key Techniques
Collection definitions
// collections/Posts.ts
import type { CollectionConfig } from 'payload';
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'publishedAt'],
},
access: {
read: () => true, // public
create: ({ req: { user } }) => Boolean(user), // authenticated
update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => user?.role === 'admin',
},
versions: {
drafts: {
autosave: true,
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
maxLength: 200,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
position: 'sidebar',
},
hooks: {
beforeValidate: [
({ value, data }) => {
if (!value && data?.title) {
return data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
return value;
},
],
},
},
{
name: 'author',
type: 'relationship',
relationTo: 'authors',
required: true,
admin: { position: 'sidebar' },
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'content',
type: 'richText', // Lexical editor
},
{
name: 'excerpt',
type: 'textarea',
maxLength: 500,
},
{
name: 'tags',
type: 'array',
fields: [
{ name: 'tag', type: 'text', required: true },
],
},
{
name: 'seo',
type: 'group',
fields: [
{ name: 'metaTitle', type: 'text', maxLength: 60 },
{ name: 'metaDescription', type: 'textarea', maxLength: 160 },
{ name: 'ogImage', type: 'upload', relationTo: 'media' },
],
},
{
name: 'publishedAt',
type: 'date',
admin: { position: 'sidebar' },
},
{
name: 'status',
type: 'select',
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
admin: { position: 'sidebar' },
},
],
};
Media collection with image optimization
// collections/Media.ts
import type { CollectionConfig } from 'payload';
export const Media: CollectionConfig = {
slug: 'media',
upload: {
mimeTypes: ['image/*'],
imageSizes: [
{ name: 'thumbnail', width: 300, height: 200, position: 'centre' },
{ name: 'card', width: 600, height: 400, position: 'centre' },
{ name: 'hero', width: 1200, height: 630, position: 'centre' },
],
adminThumbnail: 'thumbnail',
},
access: {
read: () => true,
},
fields: [
{ name: 'alt', type: 'text', required: true },
{ name: 'caption', type: 'text' },
],
};
Local API (zero network overhead)
// app/blog/page.tsx
import { getPayload } from 'payload';
import config from '@payload-config';
export default async function BlogPage() {
const payload = await getPayload({ config });
const posts = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
publishedAt: { less_than_equal: new Date().toISOString() },
},
sort: '-publishedAt',
limit: 20,
depth: 1, // resolve relationships 1 level deep
});
return (
<ul>
{posts.docs.map((post) => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}
Collection hooks
// collections/Posts.ts (hooks section)
import type { CollectionConfig } from 'payload';
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
beforeChange: [
async ({ data, operation }) => {
// Auto-set publishedAt when status changes to published
if (operation === 'create' || operation === 'update') {
if (data.status === 'published' && !data.publishedAt) {
data.publishedAt = new Date().toISOString();
}
}
return data;
},
],
afterChange: [
async ({ doc, operation, req }) => {
// Revalidate Next.js cache
if (operation === 'create' || operation === 'update') {
const { revalidateTag } = await import('next/cache');
revalidateTag('posts');
revalidateTag(`post-${doc.slug}`);
}
},
],
},
// ... fields
fields: [],
};
Access control
// access/isAdminOrAuthor.ts
import type { Access } from 'payload';
export const isAdminOrAuthor: Access = ({ req: { user } }) => {
if (!user) return false;
if (user.role === 'admin') return true;
// Authors can only see their own posts
return {
author: {
equals: user.id,
},
};
};
// Usage in collection
// access: {
// read: () => true,
// update: isAdminOrAuthor,
// delete: isAdminOrAuthor,
// },
Custom endpoints
// collections/Posts.ts
export const Posts: CollectionConfig = {
slug: 'posts',
endpoints: [
{
path: '/slug/:slug',
method: 'get',
handler: async (req) => {
const slug = req.routeParams?.slug as string;
const payload = req.payload;
const result = await payload.find({
collection: 'posts',
where: { slug: { equals: slug }, status: { equals: 'published' } },
depth: 2,
limit: 1,
});
if (result.docs.length === 0) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
return Response.json(result.docs[0]);
},
},
],
fields: [],
};
Field-level hooks and validation
{
name: 'email',
type: 'email',
required: true,
unique: true,
validate: (value: string) => {
if (value && !value.endsWith('@company.com')) {
return 'Must be a company email address';
}
return true;
},
hooks: {
beforeChange: [
({ value }) => value?.toLowerCase().trim(),
],
},
}
Best Practices
- Run
payload generate:typesafter schema changes to keep TypeScript types in sync - Use the local API (
getPayload) instead of REST when code runs in the same process - Use
depthto control relationship resolution — avoid deep nesting for list views - Use
versions.draftsfor editorial workflows with autosave - Put access control on every collection — default is open to all authenticated users
- Use
groupfields for logically related data (SEO, social sharing, metadata) - Use field-level hooks for data normalization (trimming, slugifying, computing)
- Use collection hooks for side effects (cache invalidation, notifications, syncing)
Anti-Patterns
- Defining collections without access control — every collection needs explicit read/write rules
- Using
depth: 0when you need related data ordepth: 5when you do not — be intentional - Putting business logic in API route handlers instead of hooks — hooks run for all API surfaces
- Not generating types after schema changes — leads to runtime errors that TypeScript would catch
- Running Payload as a separate service from your Next.js app — it is designed to run inside it
- Using the REST API from server components when the local API is available — adds unnecessary latency
- Skipping
unique: trueon slug fields — causes duplicate URL conflicts - Hardcoding user role checks instead of using access control functions — misses API and admin panel access
Install this skill directly: skilldb add cms-services-skills
Related Skills
Builder Io
Builder.io is a Visual Headless CMS and API that empowers developers to integrate
Caisy
caisy is a headless CMS designed for speed and scalability, empowering developers
Contentful
Build with Contentful for headless content management. Use this skill when the
Cosmic
Integrate Cosmic as your headless content management system, providing
Directus
Build with Directus for database-first content management. Use this skill
Ghost
Integrate Ghost as a powerful headless CMS or a full-featured publishing platform.