| tags |
topic |
sources |
created |
|
|
payloadcms |
|
2026-05-15 |
PayloadCMS — Versions, Drafts & Autosave
Overview
Three levels of versioning, each building on the previous:
- Versions only — audit log / history; every save creates a snapshot; newest = "published"
- Versions + Drafts —
_status field controls published vs. draft; unpublished changes possible
- Versions + Drafts + Autosave — Admin UI saves drafts automatically at a configurable interval
All versions are stored in a separate _slug_versions collection; shape of the main collection is unchanged. Fully opt-in; can be toggled off at your risk.
Setup
Collection config
import type { CollectionConfig } from 'payload'
export const Pages: CollectionConfig = {
slug: 'pages',
versions: {
maxPerDoc: 50, // default 100; 0 = unlimited
drafts: {
autosave: {
interval: 800, // ms, default 800
showSaveDraftButton: false,
},
schedulePublish: true,
validate: false, // skip required-field validation on draft save
localizeStatus: false, // beta: per-locale _status
},
},
}
Global config
// globals/Header.ts
{
slug: 'header',
versions: {
max: 20, // (globals use `max`, not `maxPerDoc`)
drafts: true,
},
}
Config Options
Versions
| Option |
Scope |
Description |
maxPerDoc |
Collection |
Max versions per document (default 100, 0=all) |
max |
Global |
Max versions for the global |
drafts |
Both |
true or drafts config object |
Drafts options
| Option |
Default |
Description |
autosave |
false |
true or autosave config object |
schedulePublish |
false |
Future publish/unpublish via Jobs Queue |
validate |
false |
Validate required fields on draft save |
localizeStatus |
false |
Beta — per-locale _status |
Autosave options
| Option |
Default |
Description |
interval |
800ms |
Debounce interval for autosave writes |
showSaveDraftButton |
false |
Show manual "Save as draft" alongside autosave |
Draft API Mechanics
Payload injects _status: 'draft' | 'published' into the schema automatically.
draft param vs _status field
| Operation |
draft param |
_status in data |
Result |
| Create |
true or false |
omitted |
Main collection updated, _status: 'draft' |
| Create |
any |
'published' |
Main collection updated, _status: 'published' |
| Update |
true |
omitted / 'draft' |
Only versions table updated; main collection frozen |
| Update |
true |
'published' |
Main collection updated as published (override) |
| Update |
false / omitted |
any |
Main collection + versions table both updated |
Key rule: draft: true skips required-field validation and writes only to the versions table — unless _status: 'published' overrides it.
Writing
// Save as draft (no required-field enforcement, main collection unchanged)
await payload.update({
collection: 'pages',
id: '...',
data: { title: 'WIP', _status: 'draft' },
draft: true,
})
// Publish
await payload.update({
collection: 'pages',
id: '...',
data: { title: 'Final', _status: 'published' },
})
Reading
// Returns published version from main collection
const published = await payload.findByID({ collection: 'pages', id: '...' })
// Returns most recent version from _versions table (may be a newer draft)
const latest = await payload.findByID({ collection: 'pages', id: '...', draft: true })
Access Control
Restrict draft visibility
access: {
read: ({ req }) => {
if (req.user) return true
// Unauthenticated users see only published docs
return { _status: { equals: 'published' } }
},
}
For collections that had no _status before enabling versions, also allow missing field:
return {
or: [
{ _status: { equals: 'published' } },
{ _status: { exists: false } },
],
}
Restrict publishing (admin only)
access: {
update: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
// Editors can only update non-published docs
return { _status: { equals: 'draft' } }
},
}
Version API
Local API
// Find versions (paginated)
await payload.findVersions({ collection: 'posts', sort: '-createdAt', limit: 10 })
await payload.findGlobalVersions({ slug: 'header' })
// Find single version
await payload.findVersionByID({ collection: 'posts', id: '<versionId>' })
await payload.findGlobalVersionByID({ slug: 'header', id: '<versionId>' })
// Restore
await payload.restoreVersion({ collection: 'posts', id: '<versionId>' })
await payload.restoreGlobalVersion({ slug: 'header', id: '<versionId>' })
REST endpoints (collections)
| Method |
Path |
Action |
| GET |
/api/{slug}/versions |
Find paginated versions |
| GET |
/api/{slug}/versions/:id |
Find version by ID |
| POST |
/api/{slug}/versions/:id |
Restore version |
Globals: same pattern under /api/globals/{globalSlug}/versions.
Database layout
_pages_versions
_id — version ID
parent — original document ID (collection only)
autosave — boolean
version — full document snapshot (all fields non-required)
createdAt
updatedAt
Gotchas
draft: true ≠ draft status — the param controls write target and validation bypass; _status controls publish state
- Existing collections — adding drafts to a live collection leaves old docs without
_status; update access control to handle _status: { exists: false }
- Scheduled publish requires Jobs Queue —
schedulePublish: true does nothing unless wiki/payloadcms/configuration and running
- Autosave is a single rolling version — Payload updates the same autosave version entry instead of creating a new one each interval; history stays clean
- Server-side Live Preview latency — lower
interval (e.g. 375ms) to make wiki/payloadcms/live-preview feel more real-time
readVersions access control — use this function to restrict who can browse the versions list in the Admin UI
validate: false default — drafts intentionally skip required-field checks; set validate: true if you need enforcement on draft saves
Related