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:

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

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

FeaturePayload CMSStrapiSanityContentful
ArchitectureNext.js nativeStandalone Node.js serverSaaS (Content Lake)SaaS
LanguageTypeScript (native)TypeScript (Strapi 5)TypeScriptN/A (API only)
DatabaseMongoDB, Postgres, SQLiteMySQL, Postgres, SQLiteHosted (proprietary)Hosted (proprietary)
Self-hostedYes (full control)Yes (full control)Studio only (data is SaaS)No
PricingFree self-host / $35+/mo cloudFree self-host / paid cloudFree tier + paid plansFree tier + paid plans
GraphQLBuilt-inPluginBuilt-inBuilt-in
Local APIYes (zero HTTP overhead)NoNoNo
AuthBuilt-inBuilt-in (Strapi 5)Third-partyThird-party
Admin PanelAuto-generated (React)Auto-generated (React)Sanity Studio (React)Hosted UI
Rich TextLexicalCustom blocksPortable TextStructured 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:

Skip Payload CMS if:


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:

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:


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.


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.