This guide is for administrators, DevOps engineers, architects, and developers who deploy, operate, extend, or contribute to Payload. It covers Payload's architecture, the complete configuration surface, security model, operations, deployment, and internals.
For installation and day-to-day usage (modeling content, querying, building features), see the companion User Guide.
Note
Payload (
payload) is the open-source core of a larger workspace of@payloadcms/*packages. This guide documents the monorepo at commit98c7c14(the4.0.0-beta.0line). Where behavior differs between database adapters or runtimes, it is called out explicitly.
Table of Contents
- Executive Summary
- System Architecture
- Repository Structure
- The Configuration Pipeline
- Complete Configuration Reference
- Database Adapters
- The Initialization Lifecycle
- Request Flow and the Four APIs
- Security
- System Collections
- Migrations
- Deployment
- Operations
- Performance and Tuning
- Extending Payload
- Module Reference
- Code Patterns and Conventions
- Testing Strategy
- Troubleshooting
- Potential Improvements
- Quick Reference
1. Executive Summary
Payload is a TypeScript-native, config-driven headless CMS and application framework that runs as part of a Next.js App Router application. Rather than being a separate server, it installs into your Next.js project, owns a (payload) route group, and exposes your content model through four coordinated interfaces: an admin panel, a REST API, a GraphQL API, and an in-process Local API.
| Attribute | Value |
|---|---|
| Language | TypeScript (ESM, NodeNext modules) |
| Runtime | Node.js ≥ 24.15.0, inside Next.js ≥ 15, React 19 |
| Architecture | pnpm + Turborepo monorepo; core payload + ~40 @payloadcms/* packages |
| Databases | MongoDB (Mongoose), PostgreSQL / SQLite / Vercel Postgres / Cloudflare D1 (Drizzle) |
| Admin UI | React Server Components + selective client components |
| License | MIT |
| Version line | 4.0.0-beta.0 (this snapshot) |
Why it matters for operators: Payload has a small operational surface. There is no separate CMS process to run — it lives in your Next.js deployment. The main external dependencies are your database, a file-storage backend (for uploads), and optionally an SMTP/email provider and a key-value store. Configuration is code (payload.config.ts), so changes are reviewable, version-controlled, and type-checked.
2. System Architecture
High-level topology
┌──────────────────────────────────────────┐
Browser / API client │ Next.js App Router App │
───────────────────► │ │
│ app/(payload)/ │
│ ├── admin/[[...segments]] ← Admin UI │
│ └── api/[...slug] ← REST/GQL │
│ │
Your front-end pages │ app/(your-site)/... ← your routes │
(RSC) ───────────────►│ │ │
│ │ getPayload({ config }) │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ payload (core engine) │ │
│ │ Local API · access · hooks · │ │
│ │ validation · versions · jobs │ │
│ └───────────────┬─────────────────┘ │
└───────────────────┼───────────────────────┘
│ db adapter (config.db)
┌────────────────┼─────────────────┐
▼ ▼ ▼
MongoDB PostgreSQL SQLite
(Mongoose) (Drizzle) (Drizzle)
Side dependencies: Storage adapter (S3/GCS/Azure/R2/Blob) · Email adapter (SMTP/Resend)
KV store (DB-backed or Redis) · Jobs queue (payload-jobs)
Layered design
| Layer | Package(s) | Responsibility |
|---|---|---|
| Core engine | payload |
Config build/sanitize, Local API (BasePayload), access control, hooks, validation, versions, localization, uploads, jobs, the database-adapter interface, error types. Runtime-agnostic. |
| Next.js integration | @payloadcms/next |
withPayload config wrapper, route handlers for REST/GraphQL, RSC admin views, server bundling. |
| Admin UI | @payloadcms/ui |
React component library that renders the admin panel from the client config. |
| GraphQL | @payloadcms/graphql |
Generates the GraphQL schema and resolvers from collections/globals. |
| Database adapters | @payloadcms/drizzle (base), db-postgres, db-sqlite, db-vercel-postgres, db-d1-sqlite, db-mongodb |
Implement the BaseDatabaseAdapter interface for a specific engine. |
| Rich text | @payloadcms/richtext-lexical |
Lexical-based editor, features, and converters. |
| i18n | @payloadcms/translations |
Admin translations and the I18n runtime. |
| Storage | @payloadcms/plugin-cloud-storage (base) + storage-* |
Pluggable upload backends. |
email-nodemailer, email-resend |
Pluggable email transports. | |
| Plugins | plugin-* |
Optional feature packs (SEO, search, forms, multi-tenant, etc.). |
| Tooling | create-payload-app, @payloadcms/sdk, codemod, typescript-plugin |
Installer, typed REST client, automated upgrades, TS server plugin. |
Important
The core
payloadpackage defines the database adapter as an interface (BaseDatabaseAdapter), not an implementation. The concrete adapter is injected at config time viadb. This is the key seam that lets the same engine run on MongoDB or any SQL database — and it's the seam to understand when debugging data-layer behavior.
3. Repository Structure
The repository is a pnpm workspace (packages/*, test/*) orchestrated by Turborepo.
payload/
├── packages/
│ ├── payload/ Core engine (config, Local API, hooks, auth, db interface)
│ ├── next/ @payloadcms/next — Next.js integration + withPayload
│ ├── ui/ @payloadcms/ui — admin React components
│ ├── graphql/ GraphQL schema generation
│ ├── richtext-lexical/ Lexical rich text editor + converters
│ ├── translations/ i18n
│ ├── drizzle/ Shared SQL adapter base (Drizzle)
│ ├── db-mongodb/ MongoDB adapter (Mongoose)
│ ├── db-postgres/ PostgreSQL adapter
│ ├── db-sqlite/ SQLite adapter
│ ├── db-vercel-postgres/ Vercel Postgres adapter
│ ├── db-d1-sqlite/ Cloudflare D1 adapter
│ ├── storage-s3/ gcs/ azure/ r2/ vercel-blob/ uploadthing/ Upload backends
│ ├── plugin-cloud-storage/ Base storage plugin
│ ├── email-nodemailer/ email-resend/ Email transports
│ ├── plugin-seo/ search/ form-builder/ nested-docs/ Official plugins
│ │ redirects/ stripe/ sentry/ multi-tenant/
│ │ import-export/ mcp/ ecommerce/
│ ├── live-preview/ live-preview-react/ live-preview-vue/ Live preview
│ ├── admin-bar/ Front-end admin bar
│ ├── kv-redis/ Redis KV adapter
│ ├── sdk/ Typed REST client (PayloadSDK)
│ ├── payload-cloud/ Payload Cloud integration
│ ├── create-payload-app/ The npx installer
│ ├── codemod/ Automated upgrade transforms
│ ├── typescript-plugin/ TS language-service plugin
│ └── eslint-config/ eslint-plugin/
├── templates/ Starter projects: blank, website, ecommerce, plugin, with-*
├── examples/ Focused example apps (auth, localization, live-preview, ...)
├── test/ Integration + e2e suites (one folder per feature area)
├── tools/ Releaser, scripts, constants
└── package.json Workspace root (turbo pipelines)
Core package internals (packages/payload/src)
src/
├── index.ts BasePayload class (Local API) + getPayload/init/reload
├── config/ buildConfig, sanitize, defaults, types, client config, definePlugin
├── collections/ Collection config types, sanitize, operations (CRUD), dataloader
├── globals/ Global config types, sanitize, operations
├── fields/ Field config types, sanitize, validation, client transforms
├── auth/ Strategies (jwt, apiKey, local), operations, access, crypto, baseFields
├── database/ Adapter interface, query validation, migrations
├── queues/ Jobs config, local API, task/workflow runner, scheduling
├── versions/ Drafts, version storage, scheduled publish, localizeStatus migration
├── uploads/ File handling (multipart, image sizing)
├── localization/ Locale resolution
├── errors/ APIError hierarchy
├── kv/ Key-value adapters (DatabaseKVAdapter default)
├── hierarchy/ Parent/child resolution, ancestors
├── bin/ CLI commands (generate:types, generate:importmap, migrate)
└── utilities/ Logger (pino), telemetry, traverseFields, flatten, formatAdminURL
4. The Configuration Pipeline
Everything starts with buildConfig(). Understanding this pipeline is essential for debugging "why is my config behaving differently than I wrote it."
buildConfig(config) [config/build.ts]
│
├─ 1. Run plugins, sorted by `order` (ascending)
│ each plugin: config → config
│
├─ 2. Run storage adapters' init() (before plugins' effects settle)
│ injects upload hooks, static handlers, presigned-URL endpoints
│
└─ 3. sanitizeConfig(config) [config/sanitize.ts]
├─ addDefaultsToConfig() — fill admin, routes, graphQL, jobs, typescript, ...
├─ sanitizeAdminConfig() — resolve admin user collection, dashboard widgets,
│ timezones, default logging levels
├─ register auth root endpoints
├─ normalize localization (string[] ⇄ Locale[], localeCodes, fallback)
├─ build i18n config (fallbackLanguage, supportedLanguages)
├─ sanitize blocks (must precede collections — join fields need them)
├─ sanitize each collection (fields, hooks, access, versions, orderable)
├─ sanitize each global
├─ resolve hierarchy collections (adds sidebar tabs)
├─ enable jobs if tasks/workflows exist; add payload-jobs collection
│ + payload-jobs-stats global (if scheduling)
├─ inject system collections: payload-locked-documents, payload-preferences,
│ payload-migrations, (query-presets if enabled)
├─ add serverURL to csrf
├─ resolve upload adapters from collections
└─ run richText editor sanitization
→ SanitizedConfig (fully resolved, validated, with system collections injected)
Note
The split between
Config(what you write) andSanitizedConfig(what Payload runs) is deliberate. Defaults, system collections, and derived fields are added during sanitization. When you inspectpayload.configat runtime you're looking at the sanitized config — which is why you'll see collections likepayload-locked-documentsyou never declared.
Tip
Plugin order matters. Plugins run sorted by their
orderproperty (default0). Storage adapters run before plugins so upload hooks and handlers are guaranteed to be present. If a plugin depends on another plugin's mutations, set itsorderhigher.
5. Complete Configuration Reference
The root config object passed to buildConfig. Required keys are marked.
Top-level options
| Key | Type | Default | Purpose |
|---|---|---|---|
secret ✳ |
string | — | Required. Hashed to secure tokens and encrypt data. |
db ✳ |
adapter | — | Required. Database adapter (mongooseAdapter, postgresAdapter, …). |
collections |
array | [] |
Your collections. |
globals |
array | [] |
Your globals. |
editor |
adapter | — | Rich text editor (lexicalEditor()). |
admin |
object | (see below) | Admin panel config. |
auth |
object | { jwtOrder: ['JWT','Bearer','cookie'] } |
Root auth behavior. |
localization |
object | false |
false |
Locale configuration. |
i18n |
object | {} |
Admin language support. |
serverURL |
string | '' |
Absolute base URL; auto-added to CSRF. |
cors |
string[] | object | [] |
Allowed CORS origins. |
csrf |
string[] | [] |
Trusted CSRF origins. |
cookiePrefix |
string | payload |
Prefix for auth cookies. |
routes |
object | /admin, /api, /graphql, /graphql-playground |
URL routes. |
endpoints |
array | [] |
Custom root REST endpoints. |
graphQL |
object | maxComplexity: 1000, playground/introspection off in prod |
GraphQL settings. |
hooks |
object | {} |
Root-level lifecycle hooks. |
jobs |
object | { deleteJobOnComplete: true, depth: 0 } |
Background jobs config. |
kv |
adapter | databaseKVAdapter() |
Key-value store. |
email |
adapter | console fallback | Email transport. |
storage |
array | [] |
Upload storage adapters. |
upload |
object | {} |
Global upload settings (e.g. busboy limits). |
plugins |
array | [] |
Plugins (run by order). |
sharp |
sharp instance | — | Pass sharp to enable image resizing. |
defaultDepth |
number | 2 |
Default relationship population depth. |
maxDepth |
number | 10 |
Maximum allowed depth (guards against runaway population). |
defaultMaxTextLength |
number | 40000 |
Default cap on text field length. |
typescript |
object | { autoGenerate: true, outputFile: 'payload-types.ts' } |
Type generation. |
telemetry |
boolean | true |
Anonymous usage telemetry. |
logger |
object | instance | pino defaults | Logging. |
onInit |
function | — | Runs once after startup with the Payload instance. |
bin |
array | [] |
Custom CLI scripts. |
queryPresets |
object | — | Saved-filter (query preset) configuration. |
Admin sub-config (admin)
| Key | Default | Purpose |
|---|---|---|
user |
first auth collection | Which collection holds admin users. |
avatar |
gravatar |
Admin avatar source. |
dateFormat |
MMMM do yyyy, h:mm a |
Date display format. |
theme |
all |
all, light, or dark. |
meta.titleSuffix |
- Payload |
Appended to admin page titles. |
meta.robots |
noindex, nofollow |
Robots meta for admin pages. |
meta.defaultOGImageType |
dynamic |
dynamic, static, or off. |
routes |
/login, /logout, /account, /forgot, /reset, /create-first-user, /unauthorized, /logout-inactivity |
Admin sub-routes. |
components |
{} |
Custom admin components (server-only). |
dashboard.widgets |
[CollectionCards] |
Dashboard widgets (modular dashboard). |
livePreview |
— | Live preview breakpoints + URL resolver. |
importMap |
{ baseDir: cwd } |
Where the generated import map lives. |
timezones |
default timezone list | Supported timezones. |
Note
Some root properties are server-only and are stripped from the client config sent to the browser:
db,editor,plugins,sharp,onInit,secret,hooks,bin,i18n,typescript,cors,csrf,custom,graphQL,jobs,logger,kv,queryPresets,storage,endpoints. This prevents leaking secrets and server internals into the admin bundle.
Jobs sub-config (jobs)
| Key | Default | Purpose |
|---|---|---|
tasks |
— | Array of task definitions (typed input/output handlers). |
workflows |
— | Array of workflow definitions (chained tasks). |
autoRun |
— | Cron schedule(s) to run jobs automatically. |
shouldAutoRun |
— | Predicate to gate autorun at runtime. |
deleteJobOnComplete |
true |
Remove jobs from the DB on success. |
depth |
0 |
Relationship depth when reading job input. |
access |
{ run, queue, cancel } |
Access control for the jobs endpoints. |
jobsCollectionOverrides |
— | Customize the auto-generated payload-jobs collection. |
6. Database Adapters
The database adapter implements the BaseDatabaseAdapter interface — a contract of methods like create, find, findOne, updateOne, deleteMany, queryDrafts, createVersion, beginTransaction/commitTransaction/rollbackTransaction, createMigration, init, connect, destroy.
| Adapter | Package | Engine | Migrations |
|---|---|---|---|
mongooseAdapter |
@payloadcms/db-mongodb |
MongoDB via Mongoose | Schemaless; migrate only for data transforms. |
postgresAdapter |
@payloadcms/db-postgres |
PostgreSQL via Drizzle | SQL migrations via payload migrate. |
sqliteAdapter |
@payloadcms/db-sqlite |
SQLite via Drizzle | SQL migrations. |
vercelPostgresAdapter |
@payloadcms/db-vercel-postgres |
Vercel Postgres | SQL migrations. |
(d1) |
@payloadcms/db-d1-sqlite |
Cloudflare D1 (SQLite) | SQL migrations. |
All SQL adapters share @payloadcms/drizzle, which builds Drizzle schemas from your sanitized fields and translates Payload queries into SQL.
// MongoDB
import { mongooseAdapter } from '@payloadcms/db-mongodb'
db: mongooseAdapter({ url: process.env.DATABASE_URI! })
// PostgreSQL
import { postgresAdapter } from '@payloadcms/db-postgres'
db: postgresAdapter({ pool: { connectionString: process.env.POSTGRES_URL! } })
// SQLite
import { sqliteAdapter } from '@payloadcms/db-sqlite'
db: sqliteAdapter({ client: { url: process.env.DATABASE_URI! } })
Warning
MongoDB transactions and transient errors. Payload wraps writes in transactions on MongoDB replica sets. Concurrent Local API writes can surface
TransientTransactionError/WriteConflictthat are not currently auto-retried (issue #16766). For high-contention writes, add application-level retry or serialize the operations.
Tip
SQL adapters support two schema-sync modes: push (apply diffs directly, dev only —
PAYLOAD_FORCE_DRIZZLE_PUSH=true) and generate + migrate (versioned files, production). Always drive these through Payload'smigrate:*CLI, which records applied migrations in thepayload-migrationscollection.
Connection failures
Each adapter throws a clear error if it can't connect: Error: missing MongoDB connection URL., Error: cannot connect to MongoDB: …, Error: cannot connect to Postgres: …, Error: cannot connect to SQLite: …. Check the connection string and that the database is reachable.
7. The Initialization Lifecycle
getPayload({ config }) is the entry point. It caches a singleton on the module scope and adds HMR support; under the hood it calls BasePayload.init().
getPayload({ config, key='default' }) [index.ts]
│ (cache hit? return cached, handle HMR reload)
└─ new BasePayload().init(options)
├─ check payload package dependency versions (dev only)
├─ load + await config; require `secret`
├─ derive 32-char key: sha256(secret).slice(0,32)
├─ build collections map; detect custom `id` field type per collection
├─ flatten blocks into a slug→block map
├─ (dev) kick off `generate:types`
├─ db = config.db.init({ payload }); db.init(); db.connect()
├─ kv = config.kv.init({ payload })
├─ load email adapter (or console fallback + warn)
├─ warn if image sizes set without sharp / uploads on Vercel without storage
├─ build authStrategies: per-collection strategies → API-key → JWT (last)
├─ run onInit (options.onInit, then config.onInit)
└─ (if cron) initialize job autorun crons
HMR (development)
In development, getPayload opens a websocket to Next.js's /_next/webpack-hmr. On a serverComponentChanges message it flags the cached instance for reload; the next getPayload call runs reload(), which destroys the DB connection, rebuilds collections/blocks/globals from the new config, regenerates types and the import map, and reconnects.
| Env var | Effect on HMR |
|---|---|
DISABLE_PAYLOAD_HMR=true |
Disable the HMR websocket entirely. |
PAYLOAD_HMR_URL_OVERRIDE |
Override the websocket URL (needed with some basePath/proxy setups). |
USE_HTTPS=true |
Use wss:// for the HMR socket. |
Caution
Changing
secretinvalidates all existing sessions and breaks any encrypted field values, because the 32-character derived key changes. TreatPAYLOAD_SECRETas a stable, long-lived secret and rotate it only with a planned re-auth/migration.
8. Request Flow and the Four APIs
All four interfaces converge on the same operation functions (createOperation, findOperation, updateOperation, …), which run access control, hooks, validation, and the database adapter in a consistent order.
Admin UI ─┐
REST ─────┤
GraphQL ──┤──► Operation (e.g. updateOperation)
Local API ┘ ├─ beforeOperation hooks
├─ access control (collection + field)
├─ beforeValidate / validation
├─ beforeChange hooks
├─ db adapter write (within a transaction)
├─ afterChange hooks
├─ afterRead hooks (shape the response)
└─ afterOperation hooks
| Interface | Transport | Access control | Best for |
|---|---|---|---|
| Local API | In-process | Bypassed by default (overrideAccess: true) |
Server code, RSC, jobs, scripts. |
| REST | HTTP under /api |
Enforced | External clients, webhooks. |
| GraphQL | HTTP /graphql |
Enforced | Typed graph queries. |
| Admin | Next.js RSC | Enforced (admin access) | Human editors. |
Important
Because every door funnels through the same operation pipeline, hooks and access control apply uniformly. A
beforeChangehook or anaccess.updatefunction protects the admin panel, REST, GraphQL, and authenticated Local API calls alike. The one exception is the Local API's defaultoverrideAccess: true, which trusts server code.
9. Security
Secrets and tokens
PAYLOAD_SECRETis hashed with SHA-256 and truncated to 32 characters to form the encryption/signing key. It is required; Payload refuses to start without it.- Auth issues JWTs, delivered as HTTP-only cookies (prefixed by
cookiePrefix, defaultpayload) and/orAuthorizationheaders. Token read order isauth.jwtOrder(default['JWT', 'Bearer', 'cookie']).
Authentication strategies
Built in init() per auth-enabled collection:
- Any custom
strategiesdeclared on the collection. - An API-key strategy if
useAPIKeyis set (Authorization: {collection} API-Key {key}). - A JWT/local strategy appended last (unless
disableLocalStrategy).
Local auth supports password hashing, account lockout (maxLoginAttempts + lockTime, surfaced as LockedAuth), email verification (verify, surfaced as UnverifiedEmail), forgot/reset password, and optional username login (loginWithUsername).
Access control
Function-based, evaluated server-side, at collection / global / field level, for each operation. Returns true, false, or a Where query for row-level security. Results are also serialized to the client so the admin UI hides actions the user can't perform.
access: {
read: ({ req: { user } }) => user ? true : { _status: { equals: 'published' } },
update: ({ req: { user } }) => user ? { author: { equals: user.id } } : false,
}
Network controls
| Control | Config | Notes |
|---|---|---|
| CORS | cors |
Allowed origins (array or */object form). |
| CSRF | csrf |
Trusted origins for cookie-based auth; serverURL is auto-added. |
| GraphQL hardening | graphQL.maxComplexity (1000), disablePlaygroundInProduction, disableIntrospectionInProduction |
Limit query cost and hide the schema in production. |
Error logging levels
To avoid noisy logs, expected errors are logged at info, not error, by default: Forbidden, Locked, MissingFile, NotFound, ValidationError. Override via loggingLevels.
Error hierarchy
ExtendableError
└── APIError (HTTP status carrier)
├── AuthenticationError ├── Forbidden ├── NotFound
├── UnauthorizedError ├── UnverifiedEmail ├── Locked / LockedAuth
├── ValidationError ├── QueryError ├── MissingFile / FileUploadError
├── InvalidConfiguration ├── DuplicateCollection ├── DuplicateFieldName / DuplicateGlobal
├── InvalidFieldName / InvalidFieldRelationship / InvalidFieldJoin / InvalidSchema
├── MissingFieldType / MissingFieldInputOptions / MissingCollectionLabel / MissingEditorProp
└── ReservedFieldName / TimestampsRequired
Tip
When you see
InvalidConfigurationor aDuplicate*/Invalid*error at startup, it's a config sanitization failure — a bad field name, duplicate slug, or misconfigured relationship — not a runtime data problem. Fix the config; these never reach production traffic because they fail fast at build.
Caution
Telemetry is on by default (
telemetry: true) and sends anonymized usage data. If your compliance posture forbids outbound telemetry, settelemetry: falsein your config.
10. System Collections
Sanitization injects several collections you didn't declare. Don't remove them; do account for them in backups and access policies.
| Collection / Global | Slug | Purpose |
|---|---|---|
| Locked documents | payload-locked-documents |
Document-level locks so two editors don't clobber each other. |
| Preferences | payload-preferences |
Per-user admin UI preferences (columns, sort, etc.). |
| Migrations | payload-migrations |
Record of applied database migrations. |
| Jobs | payload-jobs |
Queued/running/completed background jobs. |
| Jobs stats | payload-jobs-stats (global) |
Tracks last-run times for scheduled jobs. |
| Query presets | (configurable) | Saved list-view filters, when queryPresets is enabled. |
11. Migrations
For SQL databases, schema changes are managed through Payload's migration CLI (built on Drizzle):
npm run payload migrate:create add_author_to_posts # generate migration file
npm run payload migrate # apply pending migrations
npm run payload migrate:status # inspect state
npm run payload migrate:down # roll back last batch
npm run payload migrate:refresh # down + up all
npm run payload migrate:reset # roll back everything
Applied migrations are tracked in payload-migrations. If a referenced migration file is missing, you'll see Migration <name> not found locally.
MongoDB data migrations
MongoDB is schemaless, so most changes need no migration. The exception is explicit data transforms — for example, the bundled localizeStatus migration that converts version _status from a single string to per-locale objects when enabling versions.drafts.localizeStatus. Such migrations are written with payload migrate:create and applied with payload migrate.
Caution
Back up before migrating. Data-transform migrations modify schema and rows (e.g. dropping
version__status, adding_statusto a locales table on SQL; reshaping documents on Mongo). The bundled migrations are idempotent and includedown(), but always snapshot the database first (pg_dump/mongodump).
12. Deployment
Payload deploys exactly like a Next.js app — there is no separate CMS process.
Build and run
npm run build # next build (Payload bundles its admin + API)
npm run start # next start
next.config must be wrapped:
import { withPayload } from '@payloadcms/next/withPayload'
export default withPayload(nextConfig, { devBundleServerPackages: false })
Serverless (Vercel and similar)
| Concern | Requirement |
|---|---|
| Uploads | Use a cloud storage adapter — the serverless filesystem is ephemeral. Payload warns at startup if an upload collection lacks one on Vercel. |
| Jobs | Trigger payload.jobs.run() from a scheduled function (e.g. a Vercel cron route) instead of relying on an always-on internal cron. |
| Server Actions body size | Raise experimental.serverActions.bodySizeLimit for large form/file submissions. |
| Database | Use a connection-pooling-friendly database (Vercel Postgres, MongoDB Atlas, Neon, etc.). |
Note
Known deployment gotcha: Vercel "one-click deploy" has had reported breakage (issue #16883). Prefer a standard Git-based Vercel project import and configure environment variables manually.
Local infrastructure for development
The repository's test/docker-compose.yml is a reference for the services Payload can exercise, useful when standing up local dependencies:
- PostgreSQL (PostGIS + pgvector image) with a streaming read replica.
- MongoDB Community 8.2 (replica set) + mongot for
$search/$vectorSearch, plus a MongoDB Atlas Local option. - Storage emulators: LocalStack (S3), Azurite (Azure), fake-gcs-server (GCS), Vercel Blob emulator.
- Redis (for the
kv-redisadapter).
Profiles (--profile postgres|mongodb|storage|redis|all) let you start only what you need.
13. Operations
Environment variables (operational)
| Variable | Purpose |
|---|---|
PAYLOAD_SECRET |
Required. Securing key. |
DATABASE_URI / POSTGRES_URL / MONGODB_URL / REDIS_URL |
Connection strings (name set by your config). |
PAYLOAD_CONFIG_PATH |
Explicit config file path (absolute or relative). |
PAYLOAD_LOG_LEVEL |
pino log level. |
NODE_ENV |
production disables dev-only behavior (type gen, HMR, GraphQL playground). |
DISABLE_PAYLOAD_HMR |
Disable dev hot reload. |
PAYLOAD_HMR_URL_OVERRIDE |
Override HMR websocket URL. |
PAYLOAD_DISABLE_DEPENDENCY_CHECKER |
Skip the payload-package version-mismatch check. |
PAYLOAD_DROP_DATABASE |
(D1/dev) drop the database on connect — destructive. |
PAYLOAD_MIGRATING |
Internal flag set during migrations. |
PAYLOAD_FORCE_DRIZZLE_PUSH |
Force Drizzle push instead of generated migrations (dev). |
USE_HTTPS |
Use HTTPS/wss locally. |
PORT |
Dev server port (used to build the HMR URL). |
Logging
Payload uses pino. Configure via logger (level, destination, redaction). Expected client errors are downgraded to info (see §9). For production, ship logs to your aggregator and set PAYLOAD_LOG_LEVEL appropriately.
Backups
Back up your database (including the system collections) and your storage backend. The payload-jobs, payload-locked-documents, and payload-preferences collections are operational state; payload-migrations is critical for SQL schema integrity.
Health and dependency checks
On startup (development), Payload checks that all @payloadcms/* packages are on matching versions and warns on mismatch — a common cause of subtle breakage after partial upgrades. Disable with PAYLOAD_DISABLE_DEPENDENCY_CHECKER=true if you intentionally mix versions (not recommended).
14. Performance and Tuning
| Lever | Effect |
|---|---|
depth |
Lower it (even 0) when you only need IDs; high depth means more joins/lookups. Bounded by maxDepth (10). |
select |
Return only needed fields to cut payload size and DB work. |
index: true |
Add indexes to fields you filter/sort on frequently. Compound indexes are supported. |
| Pagination | Use limit/page; avoid unbounded find. |
indexSortableFields (MongoDB) |
Auto-index sortable fields. |
| Autosave interval | Tune versions.drafts.autosave.interval — too low means frequent writes. |
| Jobs concurrency | Control payload.jobs.run({ limit }) and queue partitioning. |
| KV store | Use @payloadcms/kv-redis instead of the DB-backed KV adapter for hot key-value workloads. |
Tip
The default
depth: 2is convenient but can be expensive on richly related data. For list views and APIs that don't need nested relationships, setdepth: 0or1and usepopulate/selectto fetch only what you render.
15. Extending Payload
Custom admin components
Inject React components at many points (field components, cells, views, dashboard widgets, providers) via admin.components and field-level admin.components. Components are referenced by import path string and resolved through a generated import map — regenerate it with payload generate:importmap after adding components.
Custom endpoints
Add REST routes on collections, globals, or the root endpoints array. Handlers receive a PayloadRequest (with req.payload, req.user, req.locale) and return a web Response. (See the User Guide for an example.)
Writing plugins
Use definePlugin for standardized, discoverable plugins:
import { definePlugin } from 'payload'
export const myPlugin = definePlugin<MyOptions>({
slug: 'my-plugin',
order: 10,
plugin: ({ config, plugins, ...options }) => {
// mutate and return config; read other plugins via the slug-keyed `plugins` map
return { ...config }
},
})
Note
The plugin API (
order,slug,options,definePlugin, thepluginsmap for cross-plugin discovery) is marked experimental in this version and may change before 4.0 stabilizes. A plain(config) => configfunction is always a valid plugin.
Custom database/email/storage adapters
All three are interfaces. A storage adapter implements { name, collections, init(config) }; an email adapter is a factory returning { sendEmail }; a database adapter implements BaseDatabaseAdapter. This is how the official adapters are built, and you can write your own against the same contracts.
16. Module Reference
| Module | Path | Responsibility |
|---|---|---|
BasePayload / getPayload |
payload/src/index.ts |
Local API surface, init, caching, HMR. |
buildConfig / sanitizeConfig |
payload/src/config/ |
Config assembly and validation. |
| Collection operations | payload/src/collections/operations/ |
Shared CRUD/version operations. |
| Field config + sanitize | payload/src/fields/config/ |
Field types, validation, client transforms. |
| Auth | payload/src/auth/ |
Strategies, operations, access, crypto, base fields. |
| Database interface | payload/src/database/ |
Adapter contract, query validation, migrations. |
| Queues | payload/src/queues/ |
Jobs config, runner, scheduling, local API. |
| Versions | payload/src/versions/ |
Drafts, version storage, scheduled publish. |
| Errors | payload/src/errors/ |
APIError hierarchy. |
@payloadcms/next |
packages/next/ |
withPayload, route handlers, RSC views, server bundling. |
@payloadcms/ui |
packages/ui/ |
Admin component library. |
@payloadcms/graphql |
packages/graphql/ |
Schema generation + resolvers. |
@payloadcms/drizzle |
packages/drizzle/ |
Shared SQL adapter base. |
@payloadcms/richtext-lexical |
packages/richtext-lexical/ |
Lexical editor + converters. |
create-payload-app |
packages/create-payload-app/ |
Installer (templates, DB/storage AST rewrites, env). |
@payloadcms/sdk |
packages/sdk/ |
PayloadSDK typed REST client. |
17. Code Patterns and Conventions
- ESM + NodeNext. All packages are
"type": "module"with.jsimport specifiers in TS source (resolved by the bundler's extension aliasing).withPayloadinjects the.cjs/.js/.mjs→.cts/.ts/.tsxaliases Next.js needs. - Config-as-data. Behavior is declared in serializable config objects (with function escape hatches for access, hooks, defaults), then sanitized into a normalized runtime form.
- Server/client config split. A
ClientConfigis derived for the browser with server-only properties stripped (see §5); fields and access logic are filtered so the admin bundle never carries secrets. - Single operation pipeline. Admin/REST/GraphQL/Local all call the same operation functions, guaranteeing consistent hooks, access, validation, and transactions.
- Adapter interfaces everywhere. Database, storage, email, KV, and rich text are all pluggable behind interfaces — the core never hard-codes an engine.
- Typed end to end. Generated
payload-types.tsplus module augmentation (GeneratedTypes,RegisteredPlugins) make slugs, documents, and plugin options type-safe. - Monorepo with Turborepo.
turbo build --filter ...builds individual packages;build:coreexcludes plugins/storage/templates for fast core iteration.
18. Testing Strategy
The test/ directory is organized one folder per feature area (auth, access-control, fields, relationships, localization, versions, uploads, queues, graphql, collections-rest, live-preview, plugin-, storage-, db, …). Each suite typically pairs:
- Integration tests (
int.spec.ts) — exercise the Local API and database directly. - End-to-end tests (
e2e.spec.ts) — drive the admin UI with Playwright.
Run via the root scripts (pnpm test, pnpm test:int, pnpm test:e2e). The test/docker-compose.yml stands up the databases and storage emulators the suites need. There is also an evals/ harness for AI-agent code-generation evaluation.
Tip
When contributing, mirror the existing pattern: put feature tests under
test/<feature>/, give each suite its owntsconfig.json(so opening one test file doesn't pull the wholetest/tree into the TS program), and use the shared DB helpers undertest/__helpers/.
19. Troubleshooting
Startup: the payload config is required / missing secret key / cannot find Payload config
Configuration wiring problems. Ensure getPayload receives config, PAYLOAD_SECRET is set, and your config file is at the @payload-config path or PAYLOAD_CONFIG_PATH. These fail fast — they never reach production traffic.
Startup: <Name> is not a valid admin user collection
admin.user points to a collection that doesn't exist or isn't auth-enabled. Set admin.user to a collection with auth: true, or let Payload pick the first auth collection automatically.
DuplicateCollection / DuplicateFieldName / ReservedFieldName / InvalidFieldRelationship
Config sanitization errors: a repeated slug, a repeated/reserved field name, or a relationship pointing at an unknown collection. Fix the offending config.
Database connection errors
cannot connect to MongoDB/Postgres/SQLite or missing MongoDB connection URL. Verify the connection string variable and that the DB is reachable. On Mongo, ensure a replica set if you rely on transactions.
HMR not reloading with basePath
Set PAYLOAD_HMR_URL_OVERRIDE, or develop without basePath (issue #16317). DISABLE_PAYLOAD_HMR=true turns HMR off.
plugin-multi-tenant 404 under Turbopack
Run dev with Webpack or pin a compatible Next.js version (issue #16286).
MongoDB TransientTransactionError / WriteConflict
Add application-level retry around contended Local API writes (issue #16766).
Stale fetch results inside jobs
Be explicit with cache options (revalidate: 0 / cache-buster) for fetches in job handlers (issue #14229).
Package version mismatch warnings
Keep all @payloadcms/* packages on the same version. The dependency checker warns on mismatch; resolve by aligning versions.
20. Potential Improvements
Observations for maintainers and adopters, grounded in this snapshot:
- Experimental plugin API. The
definePlugin/order/slug/plugins-map surface is marked experimental and slated to stabilize for 4.0. Plugin authors should expect possible changes and pin Payload versions. - MongoDB transient-transaction retry. Auto-retry for
TransientTransactionError/WriteConflicton Local API writes would remove a sharp edge for high-write Mongo deployments (issue #16766). basePath+ HMR. The HMR websocket URL derivation interacts awkwardly with Next.jsbasePath; tighter detection would reduce the need forPAYLOAD_HMR_URL_OVERRIDE(issue #16317).- Turbopack compatibility. Some plugins (multi-tenant) and the type-generation path (
json-schema-to-typescript) have Turbopack edge cases; broader Turbopack support is an ongoing effort. - Deprecated
defaultsexport.config/defaults.tsnotes the shareddefaultsobject is error-prone (mutating it affects consumers) and is slated for removal in 4.0 in favor ofaddDefaultsToConfig.
21. Quick Reference
CLI commands
| Command | Action |
|---|---|
payload generate:types |
Regenerate payload-types.ts. |
payload generate:importmap |
Regenerate the admin import map. |
payload migrate |
Apply pending migrations. |
payload migrate:create <name> |
Create a migration from schema diff. |
payload migrate:status |
Show migration state. |
payload migrate:down / :refresh / :reset |
Roll back / re-run / reset migrations. |
Default routes
| Route | Default | Key |
|---|---|---|
| Admin | /admin |
routes.admin |
| REST | /api |
routes.api |
| GraphQL | /graphql |
routes.graphQL |
| GraphQL playground | /graphql-playground |
routes.graphQLPlayground |
Config defaults
| Setting | Default |
|---|---|
defaultDepth / maxDepth |
2 / 10 |
defaultMaxTextLength |
40000 |
cookiePrefix |
payload |
graphQL.maxComplexity |
1000 |
telemetry |
true |
jobs.deleteJobOnComplete |
true |
auth.jwtOrder |
['JWT','Bearer','cookie'] |
typescript.outputFile |
payload-types.ts |
System collections
payload-locked-documents · payload-preferences · payload-migrations · payload-jobs · payload-jobs-stats (global) · query presets (optional).
Database adapters
mongooseAdapter (db-mongodb) · postgresAdapter (db-postgres) · sqliteAdapter (db-sqlite) · vercelPostgresAdapter (db-vercel-postgres) · Cloudflare D1 (db-d1-sqlite).
Storage adapters
s3Storage · gcsStorage · azureStorage · r2Storage · vercelBlobStorage · uploadthingStorage (base: plugin-cloud-storage).