payload
Builds full-stack applications with Payload CMS, the Next.js-native headless CMS. Use when creating content-driven apps with TypeScript, code-first configuration, and full control over your backend.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
Payload CMS
Open-source, Next.js-native headless CMS with full TypeScript support. Code-first configuration, self-hosted, with REST and GraphQL APIs.
Quick Start
npx create-payload-app@latest my-app
cd my-app
npm run dev
Opens admin at http://localhost:3000/admin.
Project Structure
my-app/
app/ # Next.js app directory
(payload)/ # Payload admin routes
collections/ # Content type definitions
payload.config.ts # Main configuration
payload-types.ts # Generated types
Configuration
// payload.config.ts
import { buildConfig } from 'payload';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
// or: import { postgresAdapter } from '@payloadcms/db-postgres';
import { Posts } from './collections/Posts';
import { Users } from './collections/Users';
import { Media } from './collections/Media';
export default buildConfig({
admin: {
user: Users.slug,
},
collections: [Users, Posts, Media],
db: mongooseAdapter({
url: process.env.MONGODB_URI!,
}),
typescript: {
outputFile: 'payload-types.ts',
},
secret: process.env.PAYLOAD_SECRET!,
});
Collections (Content Types)
// collections/Posts.ts
import { CollectionConfig } from 'payload';
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'status', 'publishedAt'],
},
access: {
read: () => true, // Public read
create: ({ req }) => !!req.user, // Authenticated only
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
versions: {
drafts: true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
unique: true,
admin: {
position: 'sidebar',
},
hooks: {
beforeValidate: [
({ value, data }) => value || data?.title?.toLowerCase().replace(/\s+/g, '-'),
],
},
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
},
{
name: 'publishedAt',
type: 'date',
admin: {
position: 'sidebar',
},
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
admin: {
position: 'sidebar',
},
},
{
name: 'content',
type: 'richText',
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'categories',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
},
],
};
Field Types
// Text
{ name: 'title', type: 'text', required: true }
// Textarea
{ name: 'excerpt', type: 'textarea' }
// Rich Text (Lexical)
{ name: 'content', type: 'richText' }
// Number
{ name: 'price', type: 'number', min: 0 }
// Email
{ name: 'email', type: 'email' }
// Date
{ name: 'publishedAt', type: 'date' }
// Checkbox
{ name: 'featured', type: 'checkbox', defaultValue: false }
// Select
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
}
// Radio
{
name: 'type',
type: 'radio',
options: ['video', 'article', 'podcast'],
}
// Relationship
{
name: 'author',
type: 'relationship',
relationTo: 'users',
}
// Upload
{
name: 'image',
type: 'upload',
relationTo: 'media',
}
// Array (repeatable)
{
name: 'gallery',
type: 'array',
fields: [
{ name: 'image', type: 'upload', relationTo: 'media' },
{ name: 'caption', type: 'text' },
],
}
// Group (nested object)
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
],
}
// Blocks (flexible content)
{
name: 'layout',
type: 'blocks',
blocks: [HeroBlock, ContentBlock, CTABlock],
}
Blocks
// blocks/Hero.ts
import { Block } from 'payload';
export const HeroBlock: Block = {
slug: 'hero',
labels: {
singular: 'Hero',
plural: 'Heroes',
},
fields: [
{ name: 'heading', type: 'text', required: true },
{ name: 'subheading', type: 'text' },
{ name: 'image', type: 'upload', relationTo: 'media' },
{
name: 'cta',
type: 'group',
fields: [
{ name: 'label', type: 'text' },
{ name: 'link', type: 'text' },
],
},
],
};
Access Control
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
// Function-based access
read: ({ req }) => {
// Published posts are public
if (!req.user) {
return { status: { equals: 'published' } };
}
// Logged in users see all
return true;
},
create: ({ req }) => !!req.user,
update: ({ req }) => {
if (!req.user) return false;
// Admins can update all
if (req.user.role === 'admin') return true;
// Authors can only update own posts
return {
author: { equals: req.user.id },
};
},
delete: ({ req }) => req.user?.role === 'admin',
},
};
Hooks
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
beforeChange: [
async ({ data, req, operation }) => {
if (operation === 'create') {
data.author = req.user?.id;
}
return data;
},
],
afterChange: [
async ({ doc, operation }) => {
if (operation === 'create') {
// Send notification, revalidate cache, etc.
await revalidatePath('/posts');
}
},
],
beforeRead: [
async ({ doc, req }) => {
// Transform document before returning
return doc;
},
],
},
fields: [/* ... */],
};
REST API
Auto-generated at /api/{collection}.
// Get all posts
const response = await fetch('/api/posts');
const { docs, totalDocs, page, limit } = await response.json();
// Get single post
const post = await fetch('/api/posts/123').then(r => r.json());
// Query with parameters
const params = new URLSearchParams({
where: JSON.stringify({
status: { equals: 'published' },
publishedAt: { less_than: new Date().toISOString() },
}),
sort: '-publishedAt',
limit: '10',
page: '1',
depth: '2',
});
const filtered = await fetch(`/api/posts?${params}`).then(r => r.json());
Query Operators
// Equals
{ field: { equals: 'value' } }
// Not equals
{ field: { not_equals: 'value' } }
// Greater/less than
{ field: { greater_than: 100 } }
{ field: { less_than: 100 } }
{ field: { greater_than_equal: 100 } }
{ field: { less_than_equal: 100 } }
// Contains (string)
{ field: { contains: 'text' } }
// In array
{ field: { in: ['a', 'b', 'c'] } }
// Not in array
{ field: { not_in: ['x', 'y'] } }
// Exists
{ field: { exists: true } }
// Logical operators
{
or: [
{ status: { equals: 'published' } },
{ featured: { equals: true } },
],
}
{
and: [
{ status: { equals: 'published' } },
{ category: { equals: 'tech' } },
],
}
GraphQL API
Available at /api/graphql.
query {
Posts(
where: { status: { equals: published } }
sort: "-publishedAt"
limit: 10
) {
docs {
id
title
slug
author {
name
}
}
totalDocs
}
}
Local API (Server-Side)
Use in Server Components, API routes, or hooks.
import { getPayload } from 'payload';
import config from '@payload-config';
const payload = await getPayload({ config });
// Find many
const posts = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
},
sort: '-publishedAt',
limit: 10,
depth: 2,
});
// Find one
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 2,
});
// Create
const newPost = await payload.create({
collection: 'posts',
data: {
title: 'New Post',
content: '...',
},
});
// Update
const updated = await payload.update({
collection: 'posts',
id: '123',
data: {
title: 'Updated Title',
},
});
// Delete
await payload.delete({
collection: 'posts',
id: '123',
});
TypeScript
Types are auto-generated to payload-types.ts.
import { Post, User } from './payload-types';
// Fully typed
const posts: Post[] = await payload.find({
collection: 'posts',
});
posts.forEach((post: Post) => {
console.log(post.title); // TypeScript knows this exists
});
Next.js Integration
// app/posts/page.tsx
import { getPayload } from 'payload';
import config from '@payload-config';
export default async function PostsPage() {
const payload = await getPayload({ config });
const { docs: posts } = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
sort: '-publishedAt',
});
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}
// app/posts/[slug]/page.tsx
import { getPayload } from 'payload';
import config from '@payload-config';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const payload = await getPayload({ config });
const { docs } = await payload.find({ collection: 'posts' });
return docs.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: 'posts',
where: { slug: { equals: params.slug } },
depth: 2,
});
if (!docs[0]) notFound();
const post = docs[0];
return (
<article>
<h1>{post.title}</h1>
{/* Render content */}
</article>
);
}
Media Collection
// collections/Media.ts
import { CollectionConfig } from 'payload';
export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: 'public/media',
imageSizes: [
{
name: 'thumbnail',
width: 400,
height: 300,
position: 'centre',
},
{
name: 'card',
width: 768,
height: 1024,
position: 'centre',
},
],
mimeTypes: ['image/*'],
},
fields: [
{ name: 'alt', type: 'text', required: true },
{ name: 'caption', type: 'text' },
],
};
Globals (Singletons)
// globals/Settings.ts
import { GlobalConfig } from 'payload';
export const Settings: GlobalConfig = {
slug: 'settings',
access: {
read: () => true,
},
fields: [
{ name: 'siteName', type: 'text', required: true },
{ name: 'siteDescription', type: 'textarea' },
{
name: 'navigation',
type: 'array',
fields: [
{ name: 'label', type: 'text' },
{ name: 'link', type: 'text' },
],
},
],
};
// Usage
const settings = await payload.findGlobal({ slug: 'settings' });
Authentication
// Login
const user = await payload.login({
collection: 'users',
data: {
email: 'user@example.com',
password: 'password',
},
});
// Current user (in hooks/access control)
const user = req.user;
// Logout
await payload.logout({
collection: 'users',
});
Deployment
# Build
npm run build
# Start production
npm run start
Deploy to:
- Vercel: One-click with Neon database
- Cloudflare: Workers + D1 + R2
- Self-hosted: Any Node.js host
Best Practices
- Use Local API in Server Components for performance
- Define access control for each collection
- Use hooks for side effects (revalidation, notifications)
-
Generate types after schema changes:
npm run generate:types - Use drafts for preview functionality
- Configure image sizes to match your frontend needs
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-01-28
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!