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
- What Payload Is
- Requirements
- Quick Start
- Installation
- Your First Collection
- Fields Reference
- Collections and Globals
- The Local API
- The REST API
- The GraphQL API
- Querying Data
- Authentication
- Access Control
- Hooks
- File Uploads and Storage
- Rich Text (Lexical)
- Drafts, Versions, and Scheduled Publishing
- Localization
- The Jobs Queue
- Plugins
- The Command-Line Interface
- Generating Types
- Common Workflows
- Troubleshooting
- FAQ
- 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
/adminby 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/adminin 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:
- Project name — becomes the folder and package name (slugified).
- Template — see the table below.
- Database — MongoDB, PostgreSQL, SQLite, and more. You can paste a connection string or accept a recommended local default.
- 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 --helpat 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
.envor your realPAYLOAD_SECRET. The installer generates a secret automatically and adds.envto.gitignore. RotatingPAYLOAD_SECRETin 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
slugis 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.useAsTitlecontrols 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
defaultValuecan 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 throwsDuplicateFieldName; reserved names throwReservedFieldName.
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), passoverrideAccess: falseand auser.
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
qsbracket syntax for nested objects:where[field][operator]=value,depth=1,locale=en,select[title]=true. The samewhereoperators 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/disableIntrospectionInProductiondefault totrue). Queries are also bounded by a complexity limit (maxComplexity, default1000) 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
whereobject with theqs-esmpackage 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: 0returns related IDs only;depth: 2(the default) resolves two levels of related documents.populate— overridedepthfor 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, andpayload.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(orPOST /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
wherequery — 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 getUnauthorizedError(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
beforeChangehook 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 defineimageSizesorformatOptionswithout installing and passingsharpto your config, Payload logs: "Image resizing is enabled for one or more collections, but sharp not installed." Installsharpand addsharpto yourbuildConfig.
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
localizationis 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 thelocalizeStatusmigration 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 andrtlflags. Use the object form when you want friendly labels in the admin and right-to-left layout for languages like Arabic or Hebrew. Withfallback: 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-jobscollection. On serverless platforms, prefer triggeringpayload.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 thedefinePluginhelper, 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 callingdrizzle-kitdirectly — Payload records applied migrations in its ownpayload-migrationscollection 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.tsso 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-runpayload generate:typesto regenerate this file.").
24. Common Workflows
Build a blog
- [ ] Create a
postscollection withtitle,slug,content(richText),author(relationship tousers),publishedDate, andfeaturedImage(upload). - [ ] Enable
versions: { drafts: true }so editors can preview before publishing. - [ ] Add a
readaccess 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
pagescollection with alayoutfield of typeblocks. - [ ] Define blocks like
hero,richText,gallery, andcta. - [ ] On the front end, map each saved block's
blockTypeto a React component.
Add a media library with cloud storage
- [ ] Create a
mediacollection withupload: { imageSizes: [...] }. - [ ] Install
sharpfor image resizing. - [ ] Add the
s3Storage(or other) adapter tostorage. - [ ] Reference media from other collections with
uploadfields.
Protect content behind login
- [ ] Set
auth: trueon auserscollection. - [ ] Add per-collection
accessfunctions keyed offreq.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).