Jesse A. Green / notebook

Live demo: the DocCompiler guide library as a static, read-only snapshot (October 2026). No backend, no accounts, nothing to submit.

Payload User Guide

Generated June 2026 from payloadcms/payload at 98c7c14

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

Payload is a TypeScript-native, code-first headless CMS and application framework that installs directly into a Next.js App Router project. You define your content model as a TypeScript config object, and Payload generates a full admin panel, a REST API, a GraphQL API, and a fully typed server-side "Local API" — all backed by the database of your choice (MongoDB, PostgreSQL, or SQLite).

This guide shows you how to install Payload, model your content, query your data, secure it, and ship features like authentication, file uploads, rich text, drafts, localization, and background jobs. Configuration deep-dives, deployment, and internals live in the companion Admin Guide.

Note

Payload is code-first. There is no clicking around a UI to define fields — your content model is a versioned TypeScript file (payload.config.ts) that lives in your repository. The admin panel is generated from that config.


Table of Contents

  1. What Payload Is
  2. Requirements
  3. Quick Start
  4. Installation
  5. Your First Collection
  6. Fields Reference
  7. Collections and Globals
  8. The Local API
  9. The REST API
  10. The GraphQL API
  11. Querying Data
  12. Authentication
  13. Access Control
  14. Hooks
  15. File Uploads and Storage
  16. Rich Text (Lexical)
  17. Drafts, Versions, and Scheduled Publishing
  18. Localization
  19. The Jobs Queue
  20. Plugins
  21. Email
  22. The Command-Line Interface
  23. Generating Types
  24. Common Workflows
  25. Troubleshooting
  26. FAQ
  27. Reference

1. What Payload Is

Payload turns a single TypeScript configuration file into a complete content backend. From your config, Payload builds:

  • An admin panel — a polished React (Server Components) dashboard for editors, generated from your fields. Mounted at /admin by default.
  • A REST API — automatic CRUD endpoints for every collection and global, served under /api.
  • A GraphQL API — a full schema with queries and mutations, served at /graphql, with a playground at /graphql-playground.
  • A Local API — a fully typed, in-process JavaScript/TypeScript API (payload.find(...), payload.create(...)) that talks to your database directly with no HTTP overhead. This is the fastest way to read and write data from your own server code.
  • TypeScript types — Payload regenerates an interface file (payload-types.ts) from your config so your whole app is type-safe end to end.

Because Payload installs into a Next.js app, your CMS and your front end share one codebase, one deployment, and one runtime. You can call the Local API from a React Server Component and render content with zero network round-trips.

Tip

A useful mental model: your config is the schema, the database is storage, and the admin panel, REST, GraphQL, and Local API are four different doors into the same data. Define a field once and it appears in all four.

Core building blocks

Concept What it is
Collection A group of documents that share a schema (e.g. posts, users, media). Maps to a table/collection in your database.
Global A single, one-off document (e.g. header, site-settings). No list — just one editable record.
Field A typed piece of data within a collection or global (text, number, relationship, rich text, etc.).
Block A reusable group of fields that editors can add, remove, and reorder — ideal for page builders.
Hook A function that runs at a point in a document's lifecycle (before save, after read, etc.).
Access control A function that decides whether a user may perform an operation, returning true, false, or a query constraint.

2. Requirements

Requirement Minimum Notes
Node.js 24.15.0 Payload's bundler targets this version; older Node is unsupported.
Next.js >= 15 Payload installs into a Next.js App Router app. The installer refuses Next.js below 15.
React 19 Bundled with current Next.js.
Database MongoDB, PostgreSQL, or SQLite Pick one adapter. See §15 and the Admin Guide.
Package manager pnpm, npm, yarn, or bun The installer detects and supports all four.
sharp optional Required only if you use image resizing on upload collections.

Important

Payload is a Next.js App Router product. It does not run on the Pages Router, and it is not a standalone Express server. If you don't already have a Next.js app, the installer scaffolds one for you.


3. Quick Start

The fastest path from nothing to a running CMS:

npx create-payload-app@latest

Answer the prompts (project name, database, template), then:

cd my-project
npm run dev

Open http://localhost:3000/admin and create your first admin user.

▲ Next.js 16.x
- Local:    http://localhost:3000
- Admin:    http://localhost:3000/admin
✓ Ready

That's it — you now have a working admin panel, REST API, and GraphQL API.

Note

The first time you visit /admin, Payload shows a "Create your first user" screen. The first user you create becomes the seed admin account. The home route / redirects to /admin in the starter templates.


4. Installation

There are two installation paths: scaffold a brand-new project, or add Payload to an existing Next.js app.

4.1 Scaffold a new project

Run the installer with your preferred package manager:

npm

npx create-payload-app@latest

pnpm

pnpm create payload-app

yarn

yarn create payload-app

bun

bunx create-payload-app

The installer walks you through:

  1. Project name — becomes the folder and package name (slugified).
  2. Template — see the table below.
  3. Database — MongoDB, PostgreSQL, SQLite, and more. You can paste a connection string or accept a recommended local default.
  4. AI coding agent (optional) — scaffolds editor rules for Claude, Codex, or Cursor.

Available templates

Template Best for
blank A minimal Payload project — just the CMS, no front end opinions.
website A full marketing/website starter with pages, posts, SEO, and a layout builder.
ecommerce A storefront starter wired up with the ecommerce plugin.
plugin A scaffold for authoring your own Payload plugin (not an app).

Installer flags

You can skip the prompts by passing flags:

npx create-payload-app@latest \
  --name my-app \
  --template website \
  --db postgres \
  --db-connection-string "postgres://user:pass@localhost:5432/mydb" \
  --use-pnpm
Flag Alias Purpose
--name <name> -n Project name.
--template <name> -t One of blank, website, ecommerce, plugin.
--db <type> -d Database adapter: mongodb, postgres, sqlite, etc.
--db-connection-string <uri> Pre-fill the database URL.
--db-accept-recommended Use the recommended local DB without prompting.
--secret <value> Set PAYLOAD_SECRET instead of auto-generating one.
--example <name> -e Scaffold from an official example instead of a template.
--branch <name> Git branch to pull the template/example from.
--use-npm / --use-pnpm / --use-yarn / --use-bun Force a package manager.
--no-deps Skip installing dependencies.
--no-git Skip git init.
--agent <type> -a AI agent rules: claude, codex, or cursor.
--no-agent Skip AI agent setup.
--beta Use beta releases of Payload.
--debug Verbose logging.
--dry-run Print what would happen without writing files.
--help -h Show help.

Tip

Run npx create-payload-app --help at any time to see the live flag list for your installed version.

4.2 Add Payload to an existing Next.js app

Run the installer inside an existing Next.js (>= 15) project directory. Payload detects the Next.js app and offers to install into it:

cd my-existing-next-app
npx create-payload-app@latest
▲ Next.js project detected!
? Install Payload in this project? › yes

The installer wraps your next.config, adds the app/(payload)/ route group, sets up a database adapter, and writes the required environment variables.

Warning

If your Next.js app has a top-level layout.tsx, the installer will warn you and ask you to move your front-end routes into a route group first. Payload needs to own the root layout for the (payload) route group. The installer prints the exact move it expects.

4.3 What gets created

A new Payload project contains:

my-project/
├── src/
│   ├── payload.config.ts        # Your content model — the heart of Payload
│   ├── collections/             # Collection definitions
│   └── app/
│       └── (payload)/           # Admin panel + API routes (Payload-owned)
│           ├── admin/[[...segments]]/   # Admin panel catch-all
│           └── api/[...slug]/            # REST + GraphQL catch-all
├── next.config.mjs              # Wrapped with withPayload()
├── .env                         # PAYLOAD_SECRET, DATABASE_URL, ...
└── package.json

4.4 Environment variables

A fresh project needs at minimum:

# .env
DATABASE_URI=mongodb://127.0.0.1/my-project
PAYLOAD_SECRET=a-long-random-string

PAYLOAD_SECRET is required — Payload hashes it to encrypt data and sign tokens. If it is missing, Payload refuses to start with Error: missing secret key. A secret key is needed to secure Payload.

The connection-string variable name varies by adapter (DATABASE_URI, POSTGRES_URL, MONGODB_URL, etc.) and is whatever your payload.config.ts reads. The starter templates use DATABASE_URI.

Caution

Never commit .env or your real PAYLOAD_SECRET. The installer generates a secret automatically and adds .env to .gitignore. Rotating PAYLOAD_SECRET in production invalidates existing sessions and breaks any encrypted field values.


5. Your First Collection

A collection is a TypeScript object. Here is a complete, minimal posts collection:

// src/collections/Posts.ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title', // which field labels each row in the admin list
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
    },
    {
      name: 'content',
      type: 'textarea',
    },
    {
      name: 'publishedDate',
      type: 'date',
    },
  ],
}

Register it in your config:

// src/payload.config.ts
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { Posts } from './collections/Posts'

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET!,
  db: mongooseAdapter({ url: process.env.DATABASE_URI! }),
  editor: lexicalEditor(),
  collections: [Posts],
})

Restart the dev server. The posts collection now appears in the admin sidebar, gets REST endpoints at /api/posts, joins the GraphQL schema, and is reachable through the Local API as payload.find({ collection: 'posts' }).

Note

The slug is the collection's identity — it names the database table/collection, the REST path, and the Local API selector. Use a stable, URL-safe, lowercase slug; renaming it later is a data migration.

Tip

admin.useAsTitle controls which field is shown as the document title in list views and relationship pickers. Without it, the admin falls back to the document ID.


6. Fields Reference

Fields are the atoms of your content model. Every field has at least a type; data-storing fields also have a name. Payload ships these field types:

Data fields

Type Stores Typical use
text string Titles, names, slugs.
textarea string (multiline) Short descriptions, summaries.
number number Prices, counts, ratings.
email string (validated) Email addresses.
code string Code snippets (monospace editor).
json arbitrary JSON Structured data, raw config.
date date Timestamps, publish dates.
point [lng, lat] Geospatial coordinates.
checkbox boolean Toggles, flags.
select string / string[] Single or multi choice from options.
radio string Single choice, shown as radio buttons.
richText structured rich text Body content (see §16).
relationship reference(s) to other docs Linking documents together.
upload reference to an upload doc Attaching files/images.
array repeating list of subfields Repeatable rows (e.g. a list of links).
blocks ordered list of typed blocks Page builders, flexible content.
group nested object of subfields Grouping related fields.
join virtual reverse relationship Showing "documents that point here."

Presentational fields

Type Purpose
row Lay out fields side by side in the admin.
collapsible Group fields under a collapsible heading.
tabs Organize fields into tabs.
ui Inject a custom admin component with no stored data.

Common field options

Most data fields accept these:

{
  name: 'slug',
  type: 'text',
  required: true,          // validation: must have a value
  unique: true,            // no two documents may share this value
  index: true,             // database index for faster queries
  defaultValue: 'draft',   // value (or function) used when none is provided
  localized: true,         // store a separate value per locale (see §18)
  label: 'URL Slug',       // admin label
  admin: {
    description: 'Auto-generated from the title',
    position: 'sidebar',   // place in the sidebar instead of the main column
    readOnly: true,
  },
  access: {                // field-level access control (see §13)
    read: () => true,
    update: ({ req }) => Boolean(req.user),
  },
  hooks: {                 // field-level hooks (see §14)
    beforeChange: [/* ... */],
  },
}

Tip

defaultValue can be a function — useful for dynamic defaults like the current user or date: defaultValue: ({ user }) => user?.id.

Relationship fields

Link documents to one another:

// One author per post
{
  name: 'author',
  type: 'relationship',
  relationTo: 'users',
}

// Many categories per post
{
  name: 'categories',
  type: 'relationship',
  relationTo: 'categories',
  hasMany: true,
}

// Polymorphic: relate to several collections
{
  name: 'related',
  type: 'relationship',
  relationTo: ['posts', 'pages'],
  hasMany: true,
}

Blocks — the page-builder field

Blocks let editors compose a document from reusable, typed sections:

{
  name: 'layout',
  type: 'blocks',
  blocks: [
    {
      slug: 'hero',
      fields: [
        { name: 'heading', type: 'text' },
        { name: 'image', type: 'upload', relationTo: 'media' },
      ],
    },
    {
      slug: 'richText',
      fields: [{ name: 'content', type: 'richText' }],
    },
  ],
}

Editors can add, remove, duplicate, and reorder blocks in the admin. Each saved block records its blockType, so your front end can switch on it to render the right component.

Note

Field names must be unique within their level and may not collide with reserved names. If you reuse a name, Payload throws DuplicateFieldName; reserved names throw ReservedFieldName.


7. Collections and Globals

Collection options

Beyond slug and fields, a collection accepts:

const Posts: CollectionConfig = {
  slug: 'posts',
  labels: { singular: 'Post', plural: 'Posts' },
  fields: [/* ... */],
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'author', 'publishedDate'],
    group: 'Content',          // sidebar grouping
    description: 'Blog posts',
  },
  access: { /* see §13 */ },
  hooks: { /* see §14 */ },
  auth: true,                  // make this an auth-enabled collection (see §12)
  upload: true,                // make this an upload collection (see §15)
  versions: { drafts: true },  // enable drafts + version history (see §17)
  timestamps: true,            // adds createdAt / updatedAt (default true)
}

Every document automatically gets an id, and (with timestamps, on by default) createdAt and updatedAt.

Globals

A global is a single editable document — perfect for site-wide settings:

import type { GlobalConfig } from 'payload'

export const Header: GlobalConfig = {
  slug: 'header',
  fields: [
    {
      name: 'navItems',
      type: 'array',
      fields: [
        { name: 'label', type: 'text' },
        { name: 'url', type: 'text' },
      ],
    },
  ],
}

Register globals under globals: [Header]. Read and write them with payload.findGlobal({ slug: 'header' }) and payload.updateGlobal(...), or via REST at /api/globals/header.

Tip

Use a collection when you have many documents of the same shape (posts, products, users). Use a global when there is exactly one (header, footer, homepage settings).


8. The Local API

The Local API is Payload's fastest interface: it runs in the same process as your app and queries the database directly — no HTTP, no serialization overhead. It is fully typed against your config. Use it in Server Components, route handlers, scripts, hooks, and jobs.

Getting a Payload instance

import { getPayload } from 'payload'
import config from '@payload-config'

const payload = await getPayload({ config })

getPayload caches the instance, so calling it repeatedly is cheap.

Reading data

// Find many (paginated)
const { docs, totalDocs, page, totalPages } = await payload.find({
  collection: 'posts',
  where: { status: { equals: 'published' } },
  limit: 10,
  page: 1,
  sort: '-publishedDate',
  depth: 1,
})

// Find one by ID
const post = await payload.findByID({
  collection: 'posts',
  id: '6634a1...',
})

// Count
const { totalDocs } = await payload.count({
  collection: 'posts',
  where: { status: { equals: 'published' } },
})

// Distinct values of a field
const { values } = await payload.findDistinct({
  collection: 'posts',
  field: 'category',
})

Writing data

// Create
const created = await payload.create({
  collection: 'posts',
  data: { title: 'Hello', content: 'World' },
})

// Update by ID
const updated = await payload.update({
  collection: 'posts',
  id: created.id,
  data: { title: 'Hello (edited)' },
})

// Update many by query
await payload.update({
  collection: 'posts',
  where: { status: { equals: 'draft' } },
  data: { status: 'published' },
})

// Delete
await payload.delete({ collection: 'posts', id: created.id })

// Duplicate
await payload.duplicate({ collection: 'posts', id: created.id })

Globals

const header = await payload.findGlobal({ slug: 'header' })

await payload.updateGlobal({
  slug: 'header',
  data: { navItems: [{ label: 'Home', url: '/' }] },
})

Common Local API options

Option Applies to Effect
where find, count, update-many, delete-many Query constraint (see §11).
depth find, create, update How many relationship levels to populate (default 2).
limit / page find Pagination.
sort find Field to sort by; prefix - for descending.
select find, create, update Return only specific fields.
locale / fallbackLocale most Which locale to read/write (see §18).
draft find, findByID Read draft versions instead of published.
user / overrideAccess most Run as a specific user, or bypass access control.

Important

By default the Local API runs with overrideAccess: true — it bypasses access control, because it is server-side and trusted. To enforce access control (e.g. when acting on behalf of a logged-in user), pass overrideAccess: false and a user.

Tip

In a React Server Component, fetch with the Local API and render directly — no API call needed:

export default async function Page() {
  const payload = await getPayload({ config })
  const { docs } = await payload.find({ collection: 'posts' })
  return <PostList posts={docs} />
}

9. The REST API

Every collection automatically gets REST endpoints under your api route (default /api).

Collection endpoints

Method Path Action
GET /api/{collection} List documents (paginated, filterable).
POST /api/{collection} Create a document.
GET /api/{collection}/{id} Get one document.
PATCH /api/{collection}/{id} Update one document.
DELETE /api/{collection}/{id} Delete one document.
GET /api/{collection}/count Count documents.

Global endpoints

Method Path Action
GET /api/globals/{slug} Read a global.
POST /api/globals/{slug} Update a global.

Examples

# List published posts, newest first
curl "http://localhost:3000/api/posts?where[status][equals]=published&sort=-publishedDate&limit=5"
{
  "docs": [ { "id": "...", "title": "Hello", "status": "published" } ],
  "totalDocs": 1,
  "limit": 5,
  "page": 1,
  "totalPages": 1,
  "hasNextPage": false
}
# Create a post (requires auth if the collection's create access demands it)
curl -X POST "http://localhost:3000/api/posts" \
  -H "Content-Type: application/json" \
  -d '{"title":"New post"}'

Note

REST query parameters use the qs bracket syntax for nested objects: where[field][operator]=value, depth=1, locale=en, select[title]=true. The same where operators as the Local API apply (see §11).

Custom endpoints

Add your own routes via endpoints on a collection, global, or the root config:

{
  slug: 'posts',
  endpoints: [
    {
      path: '/featured',
      method: 'get',
      handler: async (req) => {
        const result = await req.payload.find({
          collection: 'posts',
          where: { featured: { equals: true } },
        })
        return Response.json(result)
      },
    },
  ],
}

This is reachable at GET /api/posts/featured. Handlers receive the PayloadRequest (with req.payload, req.user, etc.) and return a standard web Response.


10. The GraphQL API

Payload generates a GraphQL schema from your collections and globals, served at /graphql with an interactive playground at /graphql-playground.

query {
  Posts(where: { status: { equals: published } }, limit: 5) {
    docs {
      id
      title
      author {
        email
      }
    }
    totalDocs
  }
}

Mutations are generated too:

mutation {
  createPost(data: { title: "Hello from GraphQL" }) {
    id
    title
  }
}

Warning

By default the GraphQL playground and introspection are disabled in production (disablePlaygroundInProduction / disableIntrospectionInProduction default to true). Queries are also bounded by a complexity limit (maxComplexity, default 1000) to prevent abusive nested queries. Configure these in the Admin Guide's GraphQL section.


11. Querying Data

All three APIs share the same query language. A query is a where object mapping field paths to operators.

where: {
  and: [
    { status: { equals: 'published' } },
    { publishedDate: { less_than_equal: new Date().toISOString() } },
    {
      or: [
        { title: { like: 'payload' } },
        { 'author.name': { contains: 'jane' } },
      ],
    },
  ],
}

Operators

Operator Meaning
equals / not_equals Exact match / mismatch.
greater_than / greater_than_equal Numeric/date comparison.
less_than / less_than_equal Numeric/date comparison.
like / contains Substring / case-insensitive substring match.
in / not_in Value is (not) one of a list.
all Array field contains all listed values.
exists Field is (or isn't) set.
near Geospatial proximity (for point fields).
within / intersects Geospatial region queries.

Combining and nesting

  • and: [...] — all conditions must match.
  • or: [...] — any condition matches.
  • Use dot paths ('author.name') to query across relationships.

Tip

When building queries on the client to send to REST, serialize the where object with the qs-esm package so the bracket notation is correct. The Local API and GraphQL take the object directly.

Selecting and populating

  • select — return only the fields you need (faster, smaller payloads).
  • depth — how deep to resolve relationships. depth: 0 returns related IDs only; depth: 2 (the default) resolves two levels of related documents.
  • populate — override depth for specific relationship fields.
await payload.find({
  collection: 'posts',
  select: { title: true, author: true },
  depth: 1,
})

12. Authentication

Enable authentication by setting auth on a collection. That collection's documents become users who can log in.

export const Users: CollectionConfig = {
  slug: 'users',
  auth: true, // or an options object (below)
  fields: [
    { name: 'name', type: 'text' },
    // email + password fields are added automatically
  ],
}

What auth gives you

  • Email + password login out of the box (the local strategy).
  • JWT tokens and HTTP-only cookies for sessions.
  • Auth operations: login, logout, refresh, "me", forgot password, reset password, unlock, verify email, register first user.
  • Auth REST endpoints under the collection, e.g. POST /api/users/login, POST /api/users/logout, GET /api/users/me, POST /api/users/forgot-password, POST /api/users/reset-password.
  • Local API auth methods: payload.login, payload.forgotPassword, payload.resetPassword, payload.unlock, payload.verifyEmail, and payload.auth (verify a request's user).

Logging in via REST

curl -X POST "http://localhost:3000/api/users/login" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email removed]","password":"secret"}'
{
  "user": { "id": "...", "email": "[email removed]" },
  "token": "eyJhbGc...",
  "exp": 1717603200
}

Auth options

auth: {
  useAPIKey: true,            // issue per-user API keys for machine access
  maxLoginAttempts: 5,        // lock the account after N failures
  lockTime: 600000,           // lock duration in ms
  verify: true,               // require email verification before login
  tokenExpiration: 7200,      // JWT lifetime in seconds
  loginWithUsername: true,    // allow username (not just email) login
  disableLocalStrategy: true, // turn off email/password (e.g. SSO-only)
  strategies: [/* custom auth strategies */],
}
Feature Option Notes
API keys useAPIKey Adds a per-user API key. Send as Authorization: {collection} API-Key {key}.
Account lockout maxLoginAttempts, lockTime Locks after repeated failures (LockedAuth error).
Email verification verify Users must verify before logging in (UnverifiedEmail error).
Username login loginWithUsername Log in with a username instead of/alongside email.
Custom strategies strategies Plug in SSO, OAuth, etc.

The order in which tokens are read is configurable via the root auth.jwtOrder (default ['JWT', 'Bearer', 'cookie']).

Caution

The first user is created through the "Create first user" flow at /admin (or POST /api/{collection}/first-register). After that, creating users is governed by your access control. Don't leave user-creation open to the public unless you intend public sign-up.


13. Access Control

Access control decides who can do what. You attach access functions to collections, globals, fields, and operations. Each function returns:

  • true — allow.
  • false — deny.
  • a where query — allow, but only for documents matching the query (row-level security).
export const Posts: CollectionConfig = {
  slug: 'posts',
  access: {
    // Anyone can read published posts; authors see their own drafts too
    read: ({ req: { user } }) => {
      if (user) return true
      return { status: { equals: 'published' } }
    },
    create: ({ req: { user } }) => Boolean(user),
    update: ({ req: { user } }) => {
      if (!user) return false
      return { author: { equals: user.id } } // only your own posts
    },
    delete: ({ req: { user } }) => user?.role === 'admin',
  },
  fields: [/* ... */],
}

Access points

Level Operations
Collection create, read, update, delete, plus readVersions and auth ops (unlock, admin).
Global read, update, readVersions.
Field read, create, update — control individual field visibility/editability.

Tip

Returning a query instead of a boolean is the idiomatic way to do multi-tenant or "owner-only" access. Payload merges that query into every read/update/delete, so users physically cannot touch rows they aren't allowed to see.

Note

When a user is denied, Payload returns a Forbidden (403) error for writes and silently filters results for reads, depending on the operation. Unauthenticated requests to protected resources get UnauthorizedError (401).


14. Hooks

Hooks are functions that run at lifecycle points. Use them to transform data, enforce rules, send notifications, or sync external systems.

Collection hooks

export const Posts: CollectionConfig = {
  slug: 'posts',
  hooks: {
    beforeValidate: [({ data }) => data],
    beforeChange: [({ data, req, operation }) => {
      if (operation === 'create') data.author = req.user?.id
      return data
    }],
    afterChange: [({ doc, operation }) => {
      if (operation === 'create') console.log('New post:', doc.id)
      return doc
    }],
    beforeRead: [({ doc }) => doc],
    afterRead: [({ doc }) => doc],
    beforeDelete: [({ id }) => { /* ... */ }],
    afterDelete: [({ doc }) => { /* ... */ }],
    afterOperation: [({ result }) => result],
    afterError: [({ error }) => { /* log */ }],
  },
  fields: [/* ... */],
}
Hook Fires
beforeOperation Before any create/update/read/delete begins.
beforeValidate Before field validation runs.
beforeChange After validation, before the database write.
afterChange After a successful create/update.
beforeRead Before a document is returned.
afterRead After a document is read (transform output).
beforeDelete / afterDelete Around deletion.
afterOperation After the whole operation completes.
afterError When an operation throws.

Auth-enabled collections add beforeLogin, afterLogin, afterLogout, afterMe, afterRefresh, afterForgotPassword.

Field hooks

Fields accept their own beforeChange, afterChange, beforeValidate, and afterRead hooks — handy for computing one field from others (e.g. auto-generating a slug from a title).

Important

Hooks run for all write paths — admin panel, REST, GraphQL, and Local API. Logic you put in a beforeChange hook is enforced everywhere, which is exactly what you usually want. Keep hooks fast; long-running work belongs in the Jobs Queue.


15. File Uploads and Storage

Enabling uploads

Set upload on a collection to turn it into a file/media collection:

export const Media: CollectionConfig = {
  slug: 'media',
  upload: {
    staticDir: 'media',
    mimeTypes: ['image/*'],
    imageSizes: [
      { name: 'thumbnail', width: 400, height: 300 },
      { name: 'card', width: 768, height: 1024 },
    ],
  },
  fields: [
    { name: 'alt', type: 'text' }, // custom metadata alongside the file
  ],
}

Upload documents automatically gain filename, mimeType, filesize, width, height, and url fields. Other collections attach files with an upload field (relationTo: 'media').

Warning

Image resizing requires sharp. If you define imageSizes or formatOptions without installing and passing sharp to your config, Payload logs: "Image resizing is enabled for one or more collections, but sharp not installed." Install sharp and add sharp to your buildConfig.

Storage adapters

By default, files are written to local disk. For production (especially serverless), use a cloud storage adapter:

Package Adapter Backend
@payloadcms/storage-s3 s3Storage Amazon S3 (and S3-compatible).
@payloadcms/storage-gcs gcsStorage Google Cloud Storage.
@payloadcms/storage-azure azureStorage Azure Blob Storage.
@payloadcms/storage-r2 r2Storage Cloudflare R2.
@payloadcms/storage-vercel-blob vercelBlobStorage Vercel Blob.
@payloadcms/storage-uploadthing uploadthingStorage UploadThing.
import { s3Storage } from '@payloadcms/storage-s3'

export default buildConfig({
  // ...
  storage: [
    s3Storage({
      collections: { media: true },
      bucket: process.env.S3_BUCKET!,
      config: { region: process.env.S3_REGION },
    }),
  ],
})

Caution

When deploying to Vercel, every upload collection needs a cloud storage adapter — the serverless filesystem is ephemeral. Payload warns at startup: "Collections with uploads enabled require a storage adapter when deploying to Vercel."


16. Rich Text (Lexical)

Payload's rich text editor is built on Meta's Lexical. Enable it as the default editor or per richText field:

import { lexicalEditor } from '@payloadcms/richtext-lexical'

export default buildConfig({
  editor: lexicalEditor(),
  // ...
})
{
  name: 'body',
  type: 'richText',
  // optionally override features for this field:
  editor: lexicalEditor({
    features: ({ defaultFeatures }) => [...defaultFeatures /* , customFeature */],
  }),
}

Features

The Lexical editor is feature-based. Built-in features include headings, bold/italic/underline/strikethrough, ordered/unordered/checklist lists, links, blockquotes, horizontal rules, indentation, uploads, relationships, inline and block-level Blocks, and an experimental table feature.

Rendering rich text on the front end

Rich text is stored as structured JSON. Convert it for display with the bundled converters:

Converter Output
Lexical → JSX/React Render in a React front end (recommended for Next.js).
Lexical → HTML Server-side HTML string (sync and async variants).
Lexical → Markdown Plain Markdown.
Lexical → Plaintext Stripped text (e.g. for search indexing or excerpts).
import { RichText } from '@payloadcms/richtext-lexical/react'

export default function Post({ content }) {
  return <RichText data={content} />
}

Tip

Because rich text is structured data (not an HTML blob), you control exactly how each node renders — including mapping embedded Blocks and relationships to your own React components.


17. Drafts, Versions, and Scheduled Publishing

Enable versioning per collection (or global):

{
  slug: 'posts',
  versions: {
    maxPerDoc: 100,        // cap stored versions per document
    drafts: {
      autosave: { interval: 800 },   // autosave drafts as the editor types
      schedulePublish: true,         // allow scheduling future publish
    },
  },
}

How drafts work

With drafts enabled, documents gain a _status field (draft or published). Saving creates a draft; publishing promotes it. The public API returns the published version by default; pass draft: true (and authenticate) to preview drafts.

// Public read — published only
const live = await payload.find({ collection: 'posts' })

// Preview drafts
const preview = await payload.find({ collection: 'posts', draft: true })

Version history

Every save records a version. List, inspect, and restore them:

const versions = await payload.findVersions({ collection: 'posts' })
await payload.restoreVersion({ collection: 'posts', id: versionId })

REST equivalents live under /api/posts/versions.

Scheduled publishing

With schedulePublish: true, editors can set a future publish (or unpublish) time. Payload registers a background job that flips _status at the scheduled moment — so scheduling depends on the Jobs Queue running.

Note

When localization is enabled, drafts can carry per-locale status (localizeStatus: true) so you can publish English while Spanish stays in draft. Enabling this on existing data requires a one-time migration (see the localizeStatus migration in the Admin Guide).


18. Localization

Turn on localization at the root, then mark individual fields localized: true:

export default buildConfig({
  localization: {
    locales: [
      { code: 'en', label: 'English' },
      { code: 'es', label: 'Español' },
      { code: 'ar', label: 'العربية', rtl: true },
    ],
    defaultLocale: 'en',
    fallback: true,
  },
  // ...
})
{ name: 'title', type: 'text', localized: true }

Only localized fields store a value per locale; everything else is shared. Read and write a specific locale with the locale option:

const es = await payload.findByID({ collection: 'posts', id, locale: 'es' })

await payload.update({
  collection: 'posts',
  id,
  locale: 'es',
  data: { title: 'Hola' },
})

Pass locale: 'all' to get every locale's value at once. REST and GraphQL accept a locale parameter too.

Tip

Locales may be plain strings (['en', 'es']) or rich objects with labels and rtl flags. Use the object form when you want friendly labels in the admin and right-to-left layout for languages like Arabic or Hebrew. With fallback: true, missing translations fall back to the default locale.


19. The Jobs Queue

Payload includes a built-in background job system for work that shouldn't block a request: sending email, generating thumbnails, syncing to third parties, or scheduled publishing.

Tasks and workflows

  • A task is a single unit of work with typed input and output.
  • A workflow chains tasks together with retry and control flow.
export default buildConfig({
  jobs: {
    tasks: [
      {
        slug: 'sendWelcomeEmail',
        inputSchema: [{ name: 'userId', type: 'text' }],
        handler: async ({ input, req }) => {
          const user = await req.payload.findByID({ collection: 'users', id: input.userId })
          await req.payload.sendEmail({ to: user.email, subject: 'Welcome!' })
          return { output: {} }
        },
      },
    ],
    deleteJobOnComplete: true, // remove finished jobs (default true)
  },
})

Queuing and running jobs

// Queue a job
await payload.jobs.queue({
  task: 'sendWelcomeEmail',
  input: { userId: user.id },
})

// Run queued jobs (e.g. from a cron route or worker)
await payload.jobs.run({ limit: 10 })

Autorun

Set jobs.autoRun to run jobs on an internal cron, and jobs.scheduling to enable scheduled tasks:

jobs: {
  autoRun: [{ cron: '* * * * *', limit: 10, queue: 'default' }],
}

Note

Jobs are stored in the payload-jobs collection. On serverless platforms, prefer triggering payload.jobs.run() from a scheduled function (e.g. a Vercel cron route) rather than relying on an always-on internal cron. Scheduled publishing (§17) is implemented as a job, so it needs the queue to be running.


20. Plugins

Plugins extend or modify your config. Add them to the plugins array; each runs in order and returns a (possibly modified) config.

import { seoPlugin } from '@payloadcms/plugin-seo'

export default buildConfig({
  plugins: [
    seoPlugin({ collections: ['posts'], uploadsCollection: 'media' }),
  ],
  // ...
})

Official plugins

Package What it adds
@payloadcms/plugin-seo Meta title/description/OG fields and previews.
@payloadcms/plugin-search A synced search collection for fast full-text lookups.
@payloadcms/plugin-form-builder Editor-built forms with submissions.
@payloadcms/plugin-nested-docs Parent/child document hierarchies (breadcrumbs).
@payloadcms/plugin-redirects Manage URL redirects from the admin.
@payloadcms/plugin-stripe Sync collections with Stripe.
@payloadcms/plugin-sentry Error reporting via Sentry.
@payloadcms/plugin-multi-tenant Multi-tenant data isolation.
@payloadcms/plugin-import-export Bulk import/export of documents (CSV).
@payloadcms/plugin-mcp Expose Payload to AI agents over the Model Context Protocol.
@payloadcms/plugin-ecommerce Products, carts, payments.
@payloadcms/plugin-cloud-storage The base layer the storage adapters build on.

Tip

Plugins are just functions of config → config. You can write your own with the definePlugin helper, which standardizes ordering (order) and identity (slug) so plugins can discover and cooperate with one another.


21. Email

Configure an email adapter so Payload can send password resets, verifications, and your own messages. Without one, email is printed to the console.

Package Adapter
@payloadcms/email-nodemailer nodemailerAdapter — SMTP and any Nodemailer transport.
@payloadcms/email-resend resendAdapter — the Resend API.
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'

export default buildConfig({
  email: nodemailerAdapter({
    defaultFromAddress: '[email removed]',
    defaultFromName: 'My App',
    transportOptions: { host: 'smtp.example.com', port: 587, auth: { /* ... */ } },
  }),
  // ...
})

Send your own email from anywhere with a Payload instance:

await payload.sendEmail({
  to: '[email removed]',
  subject: 'Hello',
  html: '<p>Hi there</p>',
})

Note

If you don't configure an adapter, Payload warns at startup — "No email adapter provided. Email will be written to console." — and password-reset/verification links appear in your server logs. That's fine for local development.


22. The Command-Line Interface

Payload ships a payload binary. The starter templates wire it up in package.json:

{
  "scripts": {
    "payload": "cross-env NODE_OPTIONS=--no-deprecation payload"
  }
}

Then run commands with your package manager:

Command Purpose
payload generate:types Regenerate payload-types.ts from your config.
payload generate:importmap Regenerate the admin import map for custom components.
payload migrate Run pending database migrations.
payload migrate:create <name> Create a new migration file from schema changes.
payload migrate:status Show which migrations have run.
payload migrate:down Roll back the last migration batch.
payload migrate:refresh Roll back and re-run all migrations.
payload migrate:reset Roll back every migration.
npm run payload generate:types
npm run payload migrate:create add_posts_table
npm run payload migrate

Important

Use Payload's migrate:* commands for SQL databases (PostgreSQL/SQLite) rather than calling drizzle-kit directly — Payload records applied migrations in its own payload-migrations collection and keeps them consistent with your config. MongoDB is schemaless and needs migrations only for explicit data transforms.


23. Generating Types

Payload keeps a generated payload-types.ts in sync with your config. In development it regenerates automatically on startup; you can also run it on demand:

npm run payload generate:types

Use the generated types throughout your app for end-to-end type safety:

import type { Post, User } from './payload-types'

const post: Post = await payload.findByID({ collection: 'posts', id })

Tip

Commit payload-types.ts so teammates and CI get the same types without running Payload. Regenerate it whenever you change your fields, and re-run it (the file even reminds you: "and re-run payload generate:types to regenerate this file.").


24. Common Workflows

Build a blog

  • [ ] Create a posts collection with title, slug, content (richText), author (relationship to users), publishedDate, and featuredImage (upload).
  • [ ] Enable versions: { drafts: true } so editors can preview before publishing.
  • [ ] Add a read access function that returns { _status: { equals: 'published' } } for anonymous users.
  • [ ] Render posts in your Next.js front end via the Local API.

Build a page builder

  • [ ] Create a pages collection with a layout field of type blocks.
  • [ ] Define blocks like hero, richText, gallery, and cta.
  • [ ] On the front end, map each saved block's blockType to a React component.

Add a media library with cloud storage

  • [ ] Create a media collection with upload: { imageSizes: [...] }.
  • [ ] Install sharp for image resizing.
  • [ ] Add the s3Storage (or other) adapter to storage.
  • [ ] Reference media from other collections with upload fields.

Protect content behind login

  • [ ] Set auth: true on a users collection.
  • [ ] Add per-collection access functions keyed off req.user.
  • [ ] Log in via POST /api/users/login; the cookie authenticates subsequent requests.

25. Troubleshooting

Error: the payload config is required to initialize payload.

Cause: getPayload/init was called without a config. Solution: Pass your config: getPayload({ config }), importing it from @payload-config. Prevention: Always import the config alias provided by the starter; don't call Payload before it's built.

Error: missing secret key. A secret key is needed to secure Payload.

Cause: PAYLOAD_SECRET is unset, so secret is empty in your config. Solution: Set PAYLOAD_SECRET in .env and read it in buildConfig({ secret: process.env.PAYLOAD_SECRET }). Prevention: Ensure .env is loaded before Payload starts; never ship without a secret.

Error: cannot find Payload config. Please create a configuration file ... "payload.config.js" or "payload.config.ts".

Cause: Payload can't locate your config file. Solution: Place payload.config.ts where your tsconfig's @payload-config path points, or set PAYLOAD_CONFIG_PATH to its absolute/relative path. Prevention: Keep the @payload-config alias in tsconfig.json (the installer adds it).

Error: cannot connect to MongoDB / cannot connect to Postgres / cannot connect to SQLite

Cause: The database isn't running or the connection string is wrong. Solution: Verify your database is up and DATABASE_URI (or POSTGRES_URL, etc.) is correct. For local development, start your DB (e.g. via Docker). Prevention: Use the connection strings the installer suggests for local dev; double-check host/port/credentials in production.

Warning: No email adapter provided. Email will be written to console.

Cause: No email adapter is configured. Solution: Add @payloadcms/email-nodemailer or @payloadcms/email-resend. For local dev, find reset/verification links in the server console. Prevention: Configure an adapter before going to production so users actually receive email.

Warning: Image resizing is enabled ... but sharp not installed.

Cause: A collection defines imageSizes/formatOptions but sharp isn't installed/passed. Solution: npm install sharp and pass sharp into buildConfig. Prevention: Install sharp whenever you use image resizing.

HMR / dev server doesn't update with a Next.js basePath

Cause: A known interaction between Payload's hot-reload websocket and Next.js basePath (community issue #16317). Solution: Develop without basePath, or set PAYLOAD_HMR_URL_OVERRIDE to the correct websocket URL. You can disable HMR entirely with DISABLE_PAYLOAD_HMR=true. Prevention: Track the upstream issue; test basePath setups carefully.

@payloadcms/plugin-multi-tenant causes a 404 with Next.js + Turbopack

Cause: A reported incompatibility with certain Next.js + Turbopack versions (issue #16286). Solution: Run the dev server with Webpack instead of Turbopack, or pin to a known-good Next.js version while a fix lands. Prevention: Verify plugin compatibility when upgrading Next.js.

MongoDB write fails with TransientTransactionError / WriteConflict

Cause: Concurrent Local API writes can hit transient transaction conflicts that aren't auto-retried (issue #16766). Solution: Retry the operation in your own code, or reduce write contention. Prevention: Serialize hot writes where possible; watch the upstream issue for a built-in retry.

fetch results seem cached across job runs even with cache: 'no-store'

Cause: Next.js fetch caching behavior in job contexts (issue #14229). Solution: Add an explicit cache-buster or use revalidate: 0; verify your Next.js version's caching semantics. Prevention: Be explicit about cache options for any fetch inside jobs.


26. FAQ

Q: Is Payload a hosted service or self-hosted? Self-hosted by default — you run it as part of your Next.js app on your own infrastructure. (A managed cloud offering exists separately.)

Q: Do I define fields in a UI? No. Your content model is the payload.config.ts file. The admin panel is generated from it. This keeps your schema in version control and type-safe.

Q: Which database should I choose? MongoDB (via Mongoose) is schemaless and great for flexible content. PostgreSQL and SQLite (via Drizzle) give you relational integrity and SQL tooling. All are first-class; pick what your team knows.

Q: Can I use Payload without the admin panel? Yes — you can use it as a headless data layer through the Local, REST, or GraphQL APIs. The admin panel is optional UI.

Q: How do I query my own data on the server? Use the Local API: const payload = await getPayload({ config }) then payload.find(...). It runs in-process with no HTTP overhead and full types.

Q: What's the difference between a collection and a global? A collection holds many documents of one shape; a global is a single document (site settings, header, footer).

Q: How do drafts work? Enable versions: { drafts: true }. Documents get a _status; public reads return published versions, and draft: true previews unpublished ones.

Q: How do I add authentication? Set auth: true on a collection. You get email/password login, JWTs, cookies, API keys, and password-reset flows automatically.

Q: How do I restrict who sees what? Use access control functions that return true, false, or a where query for row-level security.

Q: How do I deploy to Vercel? Use a cloud storage adapter for uploads (the filesystem is ephemeral) and trigger the jobs queue from a cron route. See the Admin Guide for full deployment guidance.

Q: Can I extend the admin UI? Yes — custom field components, views, and dashboard widgets are supported via the admin config. See the Admin Guide.

Q: How do I keep my TypeScript types in sync? Run payload generate:types (automatic in dev) and import from payload-types.ts.

Q: Where do background tasks run? In the built-in Jobs Queue (jobs.tasks / jobs.workflows), triggered by payload.jobs.run() or an autorun cron.

Q: Can I migrate my database schema? Yes — use payload migrate:create and payload migrate for SQL databases. MongoDB only needs migrations for explicit data transforms.

Q: Does Payload support localization? Yes — configure localization with your locales and mark fields localized: true. Right-to-left languages are supported.

Q: How do I render rich text? Rich text is structured JSON; use the Lexical converters (JSX/HTML/Markdown/plaintext) — e.g. the <RichText> React component.


27. Reference

Default routes

Route Default Config key
Admin panel /admin routes.admin
REST API /api routes.api
GraphQL /graphql routes.graphQL
GraphQL playground /graphql-playground routes.graphQLPlayground

Key config defaults

Setting Default
defaultDepth (relationship population) 2
maxDepth 10
defaultMaxTextLength 40000
cookiePrefix payload
graphQL.maxComplexity 1000
telemetry true (anonymous; can be disabled)
auth.jwtOrder ['JWT', 'Bearer', 'cookie']

Common environment variables

Variable Purpose
PAYLOAD_SECRET Required. Secret used to secure Payload.
DATABASE_URI (or POSTGRES_URL, MONGODB_URL, …) Database connection string.
PAYLOAD_CONFIG_PATH Explicit path to your config file.
PAYLOAD_LOG_LEVEL Logger verbosity.
DISABLE_PAYLOAD_HMR Disable hot reload in development.
PAYLOAD_HMR_URL_OVERRIDE Override the HMR websocket URL (basePath setups).
NODE_ENV development / production toggles.

Local API methods (quick reference)

Method Action
find, findByID, count, findDistinct Read collection documents.
create, update, delete, duplicate Write collection documents.
findGlobal, updateGlobal Read/write globals.
findVersions, findVersionByID, restoreVersion, countVersions Version operations.
login, auth, forgotPassword, resetPassword, unlock, verifyEmail Auth operations.
jobs.queue, jobs.run Background jobs.
sendEmail Send email through the configured adapter.

Field types (quick reference)

text, textarea, number, email, code, json, date, point, checkbox, select, radio, richText, relationship, upload, array, blocks, group, join (data) · row, collapsible, tabs, ui (presentational).