Quick answer: Turn on versions on a collection or global and Payload starts keeping a history of every document change, with maxPerDoc capping how many versions are retained. Add drafts and Payload also injects a _status field with draft and published values, so an editor can save work without it going live, and reads accept draft: true to fetch the newest saved version instead of the published document. autosave saves that draft on a debounced interval, and schedulePublish queues a background job that publishes at a set time — provided your deployment actually runs the jobs queue.
Part 10 of our Payload CMS series. If you have not built a collection yet, read install Payload 3 and build your first collection and globals, hooks and reused collections first.
Table of Contents
1. Versions and drafts are two separate switches
This is the first thing to get straight, because almost every confusing draft bug starts with treating them as one feature.
- Versions are history. Every change is recorded as a version row, so you can look at what a document looked like last Tuesday and restore it.
- Drafts are a working state. They let one editor save a half-finished document while the published version keeps serving traffic.
Drafts are built on top of versions, so enabling drafts without versions is not a configuration you can make — versions come first, and drafts are a mode of them. Autosave is a third layer on top of drafts: a save that happens without the editor pressing anything.
2. Turning versioning on
Versioning is opt-in per collection and per global:
// collections/Posts.ts — excerpt
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
versions: {
maxPerDoc: 100,
drafts: true,
},
fields: [
{ name: 'title', type: 'text', required: true },
],
}
Retention is configured with maxPerDoc on a collection: an integer, defaulting to 100, and 0 if you want to keep everything. Watch the naming, because globals are different — a global’s version config uses max, not maxPerDoc:
// globals/SiteSettings.ts — excerpt
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
versions: {
max: 100,
drafts: true,
},
fields: [ /* ... */ ],
}
versions: true is valid shorthand when you want history without drafts. Once drafts are on, drafts can also be an object with autosave, schedulePublish, validate and a localizeStatus option that is still marked beta. That object form is where the interesting behaviour lives.
3. _status: the field drafts adds for you
Enabling drafts injects a _status field with exactly two values: draft and published. That is the whole state machine, and it is worth resisting the urge to add a third one of your own. The admin’s “Changed” indicator — a published document that has newer unpublished edits — is a visual affordance over the version table, not a third status value.
Then the part that catches people out: _status is not an access filter by itself. Drafts are visible to whoever your access rules allow. If anonymous visitors can read the collection, draft: true on a query can hand them an unpublished document. The protection is an access-control constraint, exactly the pattern from the RBAC article:
// collections/Posts.ts — access excerpt
access: {
read: ({ req: { user } }) => (user ? true : { _status: { equals: 'published' } }),
update: ({ req: { user } }) => (user?.role === 'admin' ? true : { _status: { equals: 'published' } }),
}
Two consequences worth designing around:
- The
readconstraint is enforced in the query, so drafts never leave the database for anonymous requests regardless of which client asks. - The
updateconstraint on_statusalso drives the admin UI. When the rule does not allow a change to_status, the Publish and Unpublish controls are hidden rather than failing after the fact. If a role insists it cannot see a publish button, this rule is usually why.
One migration detail: documents created before drafts were enabled may have no _status at all until they are saved again. Allow for a missing status in access rules and frontend queries while you roll drafts out, instead of assuming the field is always present.
4. Autosave: one version, updated in place
Autosave keeps a draft current without anyone pressing Save:
versions: {
drafts: {
autosave: {
interval: 800,
showSaveDraftButton: false,
},
},
}
autosave: true uses the defaults; the object form sets the debounced interval in milliseconds (800 by default) and decides whether the manual Save-as-draft button stays visible (showSaveDraftButton, default false).
Three behaviours are worth knowing before an editor discovers them by accident:
- Autosave writes to the versions table, not the published document. The live page keeps serving the published version until someone publishes.
- It creates one autosave version and updates that version on subsequent saves. It does not append a new version every 800 ms — which is what keeps a long editing session from shredding your history.
- Autosave drives the drafts table, not your content. If a scheduled publish job later looks at the published document, autosave is irrelevant to it.
What the docs do not promise is what happens to an in-flight autosave if the editor closes the tab between typing and the next interval. Treat the last autosave as the last durable save, not as every keystroke, and keep the manual Save-draft button for anyone who wants certainty.
5. Reading drafts
The draft: true argument changes what a read returns. Without it you get the main document — the published one. With it you get the most recent version from the versions table:
const { docs } = await payload.find({
collection: 'posts',
draft: true,
where: { slug: { equals: 'about' } },
})
Over REST the same switch is a query parameter, and _status is a queryable field in a where filter:
# latest draft version of a collection document
GET /api/posts?draft=true
# one document's latest draft version
GET /api/posts/42?draft=true
# drafts only, by status
GET /api/posts?where[_status][equals]=draft
On writes, draft: true relaxes validation for incomplete saves and, on subsequent updates, writes to the versions table instead of the main document. Publishing is still expressed as _status: 'published', and the docs note that this can take precedence over draft: true — so do not send both and expect the draft to win.
The typical consumer of draft: true is a preview route on your frontend: a URL that reads the draft version so an editor can check an unpublished page — or a page layout they cannot judge from the admin panel alone — before it goes live. Wire it behind an authenticated route, because the parameter itself grants nothing.
6. The versions API
History is exposed through its own endpoints, separate from ordinary document reads:
| Method | Path | Purpose |
|---|---|---|
GET | /api/{collectionSlug}/versions | Query and paginate versions |
GET | /api/{collectionSlug}/versions/:id | Fetch one version by its version ID |
POST | /api/{collectionSlug}/versions/:id | Restore that version |
GET | /api/globals/{globalSlug}/versions | Global version history |
GET | /api/globals/{globalSlug}/versions/:id | One global version |
The Local API mirrors it with three documented operations:
await payload.findVersions({ collection: 'posts', limit: 10, sort: '-createdAt' })
await payload.findVersionByID({ collection: 'posts', id: versionId })
await payload.restoreVersion({ collection: 'posts', id: versionId })
All three accept the usual depth, user and overrideAccess options, so access checks behave the way they do everywhere else. restoreVersion is the one that changes live state: it takes the version you point at and restores it as the document.
Where teams expect a symmetric API and do not get one is deletion. There is no documented public “delete these versions” operation and no REST delete route in the versions endpoint list; retention is handled by maxPerDoc (and max on globals). Plan pruning as a retention setting, not as a cleanup script, and if you need one anyway, treat it as an unsupported operation against the database rather than a Payload feature.
7. Reviewing history without the admin panel
The admin’s Versions tab and its “Restore this version” action cover the editorial workflow. What developers usually need on top of that is a way to see history without clicking through the UI, and the endpoints above already give you it: findVersions with a where on createdAt answers “what changed since the deploy?” far faster than opening documents.
Two habits make history more useful:
- Version rows carry the data as it was saved, so a diff against the current document is a real audit trail rather than a log of field names.
- Version IDs are their own identifiers. Store them when you link to a specific revision, and do not assume they match the document’s ID.
8. Scheduled publishing needs a queue that runs
schedulePublish is a drafts option, not an autosave one:
versions: {
drafts: {
schedulePublish: true,
autosave: true,
},
}
With it enabled, the admin gains the ability to pick a future time for publishing or unpublishing, and Payload creates a background job for that action. The important half of that sentence is the second one: Payload queues the job, your deployment runs it. The jobs queue can be driven by a dedicated server process with auto-run enabled, or on serverless hosts by an external cron hitting the job endpoints. No runner, no publish, and no error on the document to explain it.
If scheduled publishing matters to your team, treat “who runs the queue” as a deployment question, not a CMS question — and add it to the deploy checklist in the production article right next to migrations. When the timing comes from your own rules rather than a timestamp an editor picked, hooks and automation keeps the alternative pattern.
9. Access control for drafts and versions
Versions need their own permission story, because reading history is a different privilege from reading content. Collections and globals both support a readVersions access function:
access: {
readVersions: ({ req: { user } }) => Boolean(user),
}
A returned query constraint narrows which versions a request can see, and the same rule governs who can open the Versions view in the admin panel. Beyond readVersions, there is no documented createVersions or deleteVersions access function: ordinary collection access rules govern document operations, and version rows are written as a side effect of saving documents. Closing those two loopholes covers the realistic cases:
- Set
readVersionsto something narrower thanreadwhen drafts contain internal notes. - Constrain
updateon_statusfor roles that must not publish, and let the admin hide the control for you.
As always with the Local API, remember the default you are overriding: access checks are skipped unless you pass overrideAccess: false with a user.
10. The errors you will actually hit
| Symptom | Cause and fix |
|---|---|
| Visitors can fetch an unpublished page | draft: true was treated as a security switch. Add an access constraint on _status so drafts never leave the database for anonymous requests |
| Draft edits overwrote the live page | The save was published rather than saved as a draft, or a hook wrote to the main document. Check which button or call ran |
| Versions accumulate far faster than expected | Autosave is creating versions where you expected one, or retention is 0. Set maxPerDoc, and remember autosave reuses a single version row |
| Scheduled publish never fires | The job was queued and nothing runs the queue. Enable auto-run on a server, or point a cron at the job endpoints |
A global rejects maxPerDoc | Globals use max, not maxPerDoc. The two configs are genuinely different shapes |
| Old documents break a status filter | They predate drafts and have no _status until resaved. Allow missing status during the rollout |
| An editor cannot find the publish button | The update access rule on _status disallows the change, so the admin hides the control |
| History is visible to someone who should not see it | readVersions is unset or returns true. Narrow it separately from read |
11. Key takeaways and challenge
- Versions are history; drafts are a working state built on that history; autosave is a debounced draft save. Three layers, one config key.
- Retention is
maxPerDocon collections andmaxon globals, and0means keep everything. - Drafts inject
_statuswithdraftandpublished. It is a stored field, not an access rule, so constrain it inreadandupdateyourself. draft: truechanges what a read returns and what a write requires; it grants nothing on its own.restoreVersionis the operation that changes live state, and version IDs are separate from document IDs.- Autosave reuses one version row per editing session, which is why it does not shred your history — and why the last autosave, not every keystroke, is what survives.
schedulePublishqueues a job; your deployment has to run the queue or nothing publishes.readVersionsis a separate privilege fromread. Set it deliberately.
Challenge: add versioning with drafts and autosave to the Posts collection from the earlier articles, give it maxPerDoc: 50, and add a reviewStatus field that only admins may update. Then write the two access rules — one on _status for update, one for readVersions — and a preview route that reads draft: true behind an authenticated check. Finally, answer two questions in writing before you test anything: which role sees the publish button and why, and what a visitor’s request returns for a post that has a newer draft sitting in the versions table. If you cannot answer both from your rules alone, the rules are not yet the ones you think they are.
Rolling out editorial drafts on a Payload site and want a review of the access rules before real content is at stake? Ampersand Academy runs one-to-one mentoring on TypeScript and Next.js backends — part of Mahadhi, a development and digital marketing company.
What is the difference between versions and drafts in Payload CMS?
Versions are a change history, so each save can be recorded and restored later. Drafts are built on top of versions and add a working state, letting an editor keep unfinished changes away from the published document.
Does draft true stop visitors from seeing unpublished content?
No. It only changes which version a read returns, the newest saved one rather than the published document. Restrict visibility with an access rule that returns a query constraint requiring _status to equal published.
How many versions does Payload keep?
Collections cap history with maxPerDoc, which defaults to 100, and globals use a max option instead. Setting either to 0 keeps all versions instead of pruning them.
Does autosave create a new version on every interval?
No. Autosave creates one autosave version and updates that same version on later saves, so a long editing session does not flood the versions table. Autosave requires both versions and drafts to be enabled.
Why is my scheduled publish not running?
Payload queues a background job for a scheduled publish, but something has to process that queue. Run a dedicated server with auto-run enabled, or trigger the job endpoints from an external cron on a serverless host.
How do I restore an older version of a document?
Use restoreVersion with the collection slug and the version ID from the Local API, or POST to the versions endpoint for that collection and version ID over REST. The restored version becomes the document, so review it before restoring in production.
Who can see the version history of a collection?
Whoever the readVersions access rule allows. It applies to collections and globals, and it also controls whether the Versions view appears in the admin panel, so treat it as a privilege separate from ordinary read access.

