Payload CMS - The TypeScript Headless CMS That Lives Inside Next.js
Most headless CMS tools make you run a separate backend. Payload CMS doesn't. Since version 3.0, Payload installs directly into your Next.js /app folder. Your CMS and your frontend are one project, one deploy, one repo.
Payload CMS is open-source, TypeScript-native, and gives you three ways to query your data: a Local API (direct database calls, no HTTP), a REST API, and GraphQL. You own the database, you own the code, you own the hosting. No vendor lock-in.
It was built by Elliot DeNolf, James Mikrut, and Dan Ribbens, three developers who spent years building client projects on other CMS platforms and got tired of fighting them. Payload went through Y Combinator in 2022, and in June 2025, Figma acquired Payload to power the CMS layer of Figma Sites. The project remains MIT-licensed and actively maintained.
What Changed in Payload CMS 3.0
If you used Payload CMS v2, forget most of what you knew about the architecture. 3.0 was a ground-up rewrite:
- Express is gone. No separate Express server, no
server.tsboot file. Payload now runs entirely within Next.js. - Next.js App Router native. The admin panel uses React Server Components. Your frontend and CMS share the same Next.js app.
- Local API in Server Components. You can query your database directly inside
page.tsxor any server component with zero HTTP overhead. - Database adapters. MongoDB, PostgreSQL, SQLite, and Vercel Postgres are all supported through a pluggable adapter system.
- Lexical rich text editor. Payload switched from Slate to Meta's Lexical editor. It's extensible with custom blocks, inline elements, and features.
- 27 dependencies (down from 88 in v2). Way leaner.
- Lazy GraphQL. GraphQL only initializes if you actually use it, so it doesn't slow down startup.
If you're reading old Payload CMS tutorials that mention Express or server.ts, they're v2. Don't follow them for a new project.
Getting Started with Payload CMS
Prerequisites
- Node.js v18+
- A database: MongoDB, PostgreSQL, or SQLite (SQLite works great for local dev)
Installation
npx create-payload-app@latest
The CLI walks you through it: project name, template, database choice, connection string. The website template is a good starting point since it demonstrates rich text blocks, live preview, and on-demand revalidation.
Once it's done:
cd my-payload-app
npm run dev
Your app runs at http://localhost:3000. The admin panel is at /admin. First time you visit, Payload CMS asks you to create an admin user.
Project Structure
A Payload CMS 3.0 project is just a Next.js project with some extra folders:
my-payload-app/
├── app/
│ ├── (frontend)/ # your site pages
│ │ ├── page.tsx
│ │ └── layout.tsx
│ └── (payload)/ # admin panel (auto-generated routes)
│ └── admin/
│ └── [[...segments]]/
│ └── page.tsx
├── collections/
│ ├── Posts.ts
│ ├── Users.ts
│ └── Media.ts
├── globals/
│ └── SiteSettings.ts
├── payload.config.ts # the main config file
├── payload-types.ts # auto-generated TypeScript types
├── next.config.mjs
└── .env
There's no separate backend folder. The CMS lives alongside your frontend code.
The Config File
payload.config.ts is the center of every Payload CMS project. This is where you wire up your database, register collections and globals, and enable plugins.
import { buildConfig } from "payload"
import { mongooseAdapter } from "@payloadcms/db-mongodb"
import { lexicalEditor } from "@payloadcms/richtext-lexical"
import sharp from "sharp"
import { Posts } from "./collections/Posts"
import { Users } from "./collections/Users"
import { Media } from "./collections/Media"
import { SiteSettings } from "./globals/SiteSettings"
export default buildConfig({
admin: {
user: Users.slug,
},
collections: [Posts, Users, Media],
globals: [SiteSettings],
editor: lexicalEditor(),
db: mongooseAdapter({
url: process.env.DATABASE_URI || "",
}),
sharp,
typescript: {
outputFile: "payload-types.ts",
},
})
Want PostgreSQL instead of MongoDB? Swap the adapter:
import { postgresAdapter } from "@payloadcms/db-postgres"
export default buildConfig({
// ...
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URI || "" },
}),
})
SQLite for local development:
import { sqliteAdapter } from "@payloadcms/db-sqlite"
export default buildConfig({
// ...
db: sqliteAdapter({
client: { url: "file:./payload.db" },
}),
})
The database adapter pattern means Payload CMS doesn't care which database you use. The API stays the same.
Defining Collections in Payload CMS
Collections are your content types: blog posts, users, products, media files. Each collection gets its own database table, API endpoints, and admin panel UI automatically.
Here's a Posts collection:
import type { CollectionConfig } from "payload"
export const Posts: CollectionConfig = {
slug: "posts",
admin: {
useAsTitle: "title",
},
access: {
read: () => true,
},
versions: {
drafts: true,
},
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
},
},
{
name: "content",
type: "richText",
},
{
name: "coverImage",
type: "upload",
relationTo: "media",
},
{
name: "author",
type: "relationship",
relationTo: "users",
},
{
name: "tags",
type: "array",
fields: [
{
name: "tag",
type: "text",
},
],
},
{
name: "publishedDate",
type: "date",
admin: {
position: "sidebar",
},
},
],
}
A few things to notice: versions: { drafts: true } enables draft/publish workflow with version history. The upload field type creates a relationship to a media collection. The array field lets editors add as many tags as they want. Payload CMS generates TypeScript types for all of this, so when you query posts in your frontend, everything is fully typed.
Globals
Globals are for singleton data like site settings, navigation, and footer content. Same field types, same hooks and access control, but there's only one document.
import type { GlobalConfig } from "payload"
export const SiteSettings: GlobalConfig = {
slug: "site-settings",
fields: [
{
name: "siteName",
type: "text",
required: true,
},
{
name: "seo",
type: "group",
fields: [
{ name: "title", type: "text" },
{ name: "description", type: "textarea" },
],
},
],
}
Hooks in Payload CMS
Hooks let you run code at specific points in the document lifecycle. This is where Payload CMS really shines. You're writing plain TypeScript functions, not wiring up some plugin system.
Available hook points: beforeValidate, beforeChange, afterChange, beforeRead, afterRead, beforeDelete, afterDelete, beforeOperation, afterOperation.
Here's a practical example, auto-generating a slug from the title:
import type { CollectionConfig } from "payload"
export const Posts: CollectionConfig = {
slug: "posts",
hooks: {
beforeChange: [
({ data }) => {
if (data?.title && !data?.slug) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
}
return data
},
],
},
fields: [
{ name: "title", type: "text", required: true },
{ name: "slug", type: "text", unique: true },
// ...
],
}
Another common pattern, sending a notification after a document is published:
hooks: {
afterChange: [
async ({ doc, previousDoc, operation }) => {
if (
operation === "update" &&
doc._status === "published" &&
previousDoc._status === "draft"
) {
await sendNotification({
title: doc.title,
message: `New post published: ${doc.title}`,
})
}
},
],
}
Field-level hooks exist too. You can run validation or transformation logic on individual fields, not just the whole document.
The Local API
This is probably the single biggest reason to pick Payload CMS over other headless CMS options. The Local API lets you query your database directly from server-side code. No HTTP requests, no REST calls, no latency.
In a Next.js Server Component:
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" },
},
sort: "-publishedDate",
limit: 10,
})
return (
<div>
{posts.docs.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
</article>
))}
</div>
)
}
This is a direct database query. No fetch call, no API route in between, no serialization overhead. And it's fully typed. post.title has autocomplete because Payload CMS generates types from your collections.
You can do the same thing in Server Actions:
"use server"
import { getPayload } from "payload"
import config from "@payload-config"
export async function createPost(formData: FormData) {
const payload = await getPayload({ config })
const post = await payload.create({
collection: "posts",
data: {
title: formData.get("title") as string,
content: formData.get("content"),
_status: "draft",
},
})
return post
}
The Local API supports everything REST and GraphQL do: find, findByID, create, update, delete, auth operations, file uploads. It's the same underlying code. REST and GraphQL are just HTTP wrappers around the Local API.
REST and GraphQL APIs in Payload CMS
If you're consuming content from a mobile app, a separate frontend, or any external client, Payload CMS auto-generates REST and GraphQL endpoints for every collection.
REST
# Get all published posts
GET /api/posts?where[_status][equals]=published&sort=-publishedDate&limit=10
# Get a single post
GET /api/posts/6507a3c8e4b0a1234567890
# Create a post (requires auth)
POST /api/posts
Authorization: Bearer <token>
Content-Type: application/json
{
"title": "My New Post",
"content": { ... }
}
# Update a post
PATCH /api/posts/6507a3c8e4b0a1234567890
Authorization: Bearer <token>
Content-Type: application/json
{
"title": "Updated Title"
}
The query syntax supports operators like equals, not_equals, greater_than, less_than, like, contains, in, exists, and even near for geospatial queries. Pagination is built in. Every response includes totalDocs, totalPages, page, hasNextPage, etc.
GraphQL
query {
Posts(where: { _status: { equals: published } }, sort: "-publishedDate") {
docs {
id
title
publishedDate
author {
name
email
}
tags {
tag
}
}
totalDocs
hasNextPage
}
}
The GraphQL playground is available at /api/graphql-playground during development.
Access Control in Payload CMS
Access control is function-based. No role strings, no permission tables. Just functions that return true, false, or a query constraint.
import type { CollectionConfig } from "payload"
export const Posts: CollectionConfig = {
slug: "posts",
access: {
// Anyone can read published posts
read: ({ req: { user } }) => {
if (user) return true
// Non-authenticated users can only see published posts
return {
_status: { equals: "published" },
}
},
// Only admins and editors can create
create: ({ req: { user } }) => {
return user?.role === "admin" || user?.role === "editor"
},
// Authors can update their own posts, admins can update any
update: ({ req: { user } }) => {
if (user?.role === "admin") return true
return {
author: { equals: user?.id },
}
},
// Only admins can delete
delete: ({ req: { user } }) => {
return user?.role === "admin"
},
},
fields: [
// ...
],
}
The trick here is that access control functions can return a query constraint (like { author: { equals: user?.id } }) instead of just a boolean. Payload CMS automatically applies that as a WHERE clause to all database queries, so users literally cannot see data they don't have access to. The rows are filtered at the database level.
Field-level access control works the same way:
{
name: "internalNotes",
type: "textarea",
access: {
read: ({ req: { user } }) => user?.role === "admin",
update: ({ req: { user } }) => user?.role === "admin",
},
}
Authentication in Payload CMS
Payload CMS has authentication built in. No Auth.js, no Clerk, no third-party service needed. Add auth: true to any collection and it becomes an auth-enabled collection:
import type { CollectionConfig } from "payload"
export const Users: CollectionConfig = {
slug: "users",
auth: true,
admin: {
useAsTitle: "email",
},
fields: [
{
name: "role",
type: "select",
options: ["admin", "editor", "viewer"],
required: true,
defaultValue: "viewer",
},
{
name: "name",
type: "text",
},
],
}
That single auth: true gives you: login, logout, password hashing, password reset via email, email verification, JWT tokens, HTTP-only cookies (XSS and CSRF protection), and API key support for server-to-server auth.
All auth operations work through REST, GraphQL, and the Local API:
// Login via Local API
const { token, user } = await payload.login({
collection: "users",
data: {
email: "dev@example.com",
password: "securepassword",
},
})
File Uploads and Media
Add upload: true to a collection and Payload CMS turns it into a media library with automatic image resizing:
import type { CollectionConfig } from "payload"
export const Media: CollectionConfig = {
slug: "media",
upload: {
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/svg+xml"],
imageSizes: [
{ name: "thumbnail", width: 300, height: 300, position: "centre" },
{ name: "card", width: 768, height: 1024, position: "centre" },
{ name: "hero", width: 1920, height: undefined, position: "centre" },
],
},
fields: [
{
name: "alt",
type: "text",
required: true,
},
],
}
By default, files are stored on disk. For production, you'll want a cloud storage adapter:
import { s3Storage } from "@payloadcms/storage-s3"
export default buildConfig({
plugins: [
s3Storage({
collections: { media: true },
bucket: process.env.S3_BUCKET || "",
config: {
region: process.env.S3_REGION || "",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || "",
secretAccessKey: process.env.S3_SECRET_KEY || "",
},
},
}),
],
// ...
})
Payload CMS also has adapters for Vercel Blob, Google Cloud Storage, Azure Blob, and Uploadthing.
Live Preview and Drafts
Payload CMS has an iframe-based live preview system. You edit content in the admin panel, and your actual frontend renders in a side panel in real time. Changes stream via window.postMessage, so you see updates before saving.
The draft system works with a _status field (draft or published). Combined with Next.js Draft Mode, you can build a full preview environment where editors see draft content and visitors see published content.
Payload CMS vs Strapi, Sanity, and Contentful
| Feature | Payload CMS | Strapi | Sanity | Contentful |
|---|---|---|---|---|
| Architecture | Next.js native | Standalone Node.js server | SaaS (Content Lake) | SaaS |
| Language | TypeScript (native) | TypeScript (Strapi 5) | TypeScript | N/A (API only) |
| Database | MongoDB, Postgres, SQLite | MySQL, Postgres, SQLite | Hosted (proprietary) | Hosted (proprietary) |
| Self-hosted | Yes (full control) | Yes (full control) | Studio only (data is SaaS) | No |
| Pricing | Free self-host / $35+/mo cloud | Free self-host / paid cloud | Free tier + paid plans | Free tier + paid plans |
| GraphQL | Built-in | Plugin | Built-in | Built-in |
| Local API | Yes (zero HTTP overhead) | No | No | No |
| Auth | Built-in | Built-in (Strapi 5) | Third-party | Third-party |
| Admin Panel | Auto-generated (React) | Auto-generated (React) | Sanity Studio (React) | Hosted UI |
| Rich Text | Lexical | Custom blocks | Portable Text | Structured content |
The biggest differentiator for Payload CMS is the Local API. With Strapi, Sanity, or Contentful, your frontend always makes HTTP requests to get content. With Payload CMS, your Server Components query the database directly. For a Next.js project, this means faster page loads, simpler architecture, and one fewer service to deploy.
Sanity and Contentful charge for API calls and storage at scale. Payload CMS is free to self-host, so you only pay for your own infrastructure. Payload Cloud exists if you want managed hosting starting at $35/month, but it's optional.
When to Use Payload CMS
Pick Payload CMS if:
- You're already on Next.js or planning to use it
- You want your CMS in the same repo and deploy as your frontend
- You need the Local API for performance (direct DB queries from server components)
- You want built-in auth without wiring up a third-party service
- Your team knows TypeScript and wants full control over the backend
- You need serious access control (row-level, field-level, query constraints)
Skip Payload CMS if:
- You're not using Next.js (the admin panel requires it, though Payload core can run elsewhere)
- You need a no-code CMS that non-developers can set up
- You don't want to manage your own hosting and database
- You need a massive plugin ecosystem right now
Deploying Payload CMS
Since Payload CMS 3.0 is a Next.js app, you deploy it like any Next.js app.
Vercel
The simplest option. Push your repo and Vercel handles the rest. You'll need:
- A database: Neon (Postgres), MongoDB Atlas, or Vercel Postgres
- File storage: Vercel Blob, S3, or another cloud storage adapter
- Environment variables for your database URI and storage credentials
Payload CMS supports serverless deployment out of the box. The one thing to watch: cold starts can be slower if your config is large, since Payload initializes on the first request.
Docker
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["npm", "run", "start"]
For production Docker deployments, pair this with a managed database (RDS, Cloud SQL, Atlas) rather than running the database in the same container.
Self-hosted VPS
Any Node.js 18+ server works. Run npm run build && npm run start behind a reverse proxy (nginx, Caddy) with SSL. Use PM2 or systemd to keep the process alive.
Scaling Payload CMS
Since Payload CMS runs on Next.js, scaling follows the same patterns:
- Serverless: Deploy to Vercel or Cloudflare. Scales automatically with traffic. Use a connection pooler (like PgBouncer or Neon's built-in pooler) if you're on PostgreSQL to avoid maxing out connections.
- Horizontal scaling: Run multiple instances behind a load balancer. Payload is stateless (auth uses JWTs), so this works without sticky sessions.
- Caching: Use Next.js ISR or on-demand revalidation. Payload CMS has revalidation hooks, so you can bust the cache whenever content changes.
- CDN: Put your media behind a CDN (CloudFront, Cloudflare). The storage adapters handle the upload, you handle the delivery.
Community and What's Next
Payload CMS has ~40,000 stars on GitHub and a growing community on Discord. The docs are solid and most things you need are covered at payloadcms.com/docs.
Since the Figma acquisition, Payload is being integrated into Figma Sites as the content management layer. The core team has said the open-source project stays MIT-licensed, and the release cadence hasn't slowed. They're shipping multiple updates per week.
- GitHub: github.com/payloadcms/payload
- Docs: payloadcms.com/docs
- Discord: payloadcms.com/community
Payload CMS hit a sweet spot that didn't really exist before: a headless CMS that doesn't feel like a separate system bolted onto your project. It's code-first, TypeScript-native, and the Local API means your CMS queries are just function calls. If you're building on Next.js and want a CMS that stays out of your way while giving you full control, Payload CMS is worth a serious look.