Publishing
Making a branch live, scheduling it, running several live branches, and how published reads differ.
Publishing marks a branch as live. A publication is a pointer to a branch, not a snapshot of its content: a row in publications, keyed by (rootId, branchId), records that a branch is published, along with the commit at publish time, who published it, and when. The published read renders the branch's current head, so later commits to a published branch go live on the next read, with no re-publish needed. Almost everything else on this page follows from that one idea: publishing aims a pointer at a moving branch, and reads track where it points.
Publish and unpublish
publishBranch publishes a branch; unpublishBranch removes a publication and takes it offline. Publishing can require approval, in which case it is blocked until granted (PUBLICATION_APPROVAL_REQUIRED):
await cms.api.pages.publishBranch({
body: { rootId, branchId },
});Nothing new is committed. The publication points at the branch's existing head, so publishing is cheap and idempotent: re-running it just re-stamps the same row.
One entry, many live branches
Publishing is per branch, not per entry, so a single root can have several branches live at once. getPublishedContent returns them all in variants, one per published branch (ordered by when each was published). This is the foundation for:
- A/B tests: the A/B testing plugin publishes a control branch and one or more variant branches; the client picks which a visitor sees.
- Gradual rollout or previewing a candidate alongside the current live version.
With a single published branch, variants[0] is simply the page; publishing more branches (a gradual rollout or an A/B test) is what adds variants. The consumer decides which variant to render (see Render content).
Reading published content
Rendering for visitors goes through getPublishedContent. Look an entry up by rootId, slug, or path, and it returns the live tree for every published branch:
const { variants } = await cms.api.pages.getPublishedContent({
query: { slug: 'welcome' },
});
// One published branch → variants[0] is the page.
render(variants[0].tree);This read is designed to be anonymous. It still runs your full auth chain, so plugin scope (multi-tenant, i18n) stays enforced and the request scope resolves. The difference is that the caller is not a logged-in editor: the convention is to let your authMiddleware allow unauthenticated reads of the publishedContent resource, so anyone can fetch live content while draft reads stay behind auth.
Which read do I use?
| You are | Use | Why |
|---|---|---|
| Rendering for visitors | getPublishedContent | Published-only, references resolved, variables substituted, returns variants. |
| Building an editor or preview | getBlockTree | Any branch and commit, including drafts; raw references; no variant logic. |
The two differ in what they assemble:
getBlockTreereturns a branch's tree as stored. Areferenceproperty stays a rawrootIdstring, and there are no variants.getPublishedContentreturns the live tree with embedded references resolved inline and variables substituted. Calling it on an entry with no publication throwsPUBLISHED_CONTENT_NOT_FOUND; read an unpublished draft withgetBlockTreeinstead.
Slugs go live at publish
The slug is versioned. Editing an entry's slug stores it on the draft (the branch's root version) and does not change the live URL until you publish. On publish, the draft slug is promoted to the live roots.slug, uniqueness is checked, and a slug change records a redirect from the old path (see Redirects). The same promotion runs whether you publish through publishBranch, a release, or a scheduled publish.
Two current limitations follow from how the draft slug is stored:
- Clearing a slug is not materialized. If you clear a draft slug back to empty (for example, promoting an
allowIndexpage to the home page), publishing does not resetroots.slug; it keeps its last published value. Telling an explicitly cleared slug apart from one that was never set is a future enhancement. - Uniqueness is checked in the active scope. Publish-time slug uniqueness is scoped to the active request scope. Under i18n, publish a translation within its own language context. Publishing it from a different language would check uniqueness against that active language, not the branch's own.
Scheduling
A publish does not have to happen now. schedulePublication and scheduleUnpublish queue a future intent in scheduled_publications (the root and branch are validated up front), and nothing changes at the wall clock until the queue is processed. Pairing the two is how you express a live window:
// Go live on launch morning...
await cms.api.pages.schedulePublication({
body: {
rootId: 'root_123',
branchId: 'branch_456',
scheduledAt: '2026-01-01T09:00:00Z',
},
});
// ...and expire it a week later.
await cms.api.pages.scheduleUnpublish({
body: {
rootId: 'root_123',
branchId: 'branch_456',
scheduledAt: '2026-01-08T09:00:00Z',
},
});A scheduled row is an intent, not a timer: it acts only when admin.runScheduled runs and finds it due (scheduledAt in the past, not yet processed), so you wire that endpoint to a cron. Each due row publishes or unpublishes through the same machinery as the immediate endpoints, so the same rules apply: approval gates, slug promotion, and the head-follows semantics. Scheduling a publish for time T therefore goes live with whatever the branch head is at T, not the head as it stood when you scheduled. A due row that hits a structural problem (a missing root, branch, or publication) is stamped processed and reported in failed, so a broken intent never re-runs forever. Any other failure, including a still-ungranted approval, is treated as transient: the row stays due and is retried on the next pass.
Revalidation
When content changes, the CMS can tell your framework to revalidate. Pass onRevalidate to createCMS to receive an event (with the affected paths and cache tags), or mount createRevalidateHandler to accept a revalidation webhook. See Use with Next.js.
Limitations
Publishing records that a branch is live, not a timeline of what was live. Two boundaries follow from that, and both are deliberate for now:
- No publication history. A publication row is keyed by
(rootId, branchId), and re-publishing updates that same row in place, socommitId,publishedBy, andpublishedAtonly ever reflect the most recent publish of that branch. There is no per-publish event log, so "what was live at time T" is not answerable from the CMS. - Reads follow the branch head, not the published commit.
getPublishedContent(andlistPublications) resolve the branch's current head (branches.headCommitId); thecommitIdstored on the publication row is recorded but not read on the render path. So a publish cannot be pinned to a specific commit: any later commit to a published branch goes live on the next read. To take a change back, move the branch head (revert on the branch), not the publication.
Pinned publishing (freezing a publication to an exact commit) and a publication-event history (an auditable log of every publish) are under consideration for a later rebuild. Until then, do not rely on the publication row as an audit trail or a rollback point: treat the branch and its commits as the source of truth for history.
For publishBranch, unpublishBranch, schedulePublication, scheduleUnpublish, getPublishedContent, and listPublications, see the Publications API.