Developer Demo
Content Lifecycle
The editorial state machine - Draft, Scheduled, Published, Expired - how each state affects Graph queries, ISR caches, and what the app needs to handle.
The editorial state machine#
Content in Optimizely CMS moves through a series of states from creation to expiry. The state controls who can edit it, whether it appears in Graph queries, and what webhook events fire. From the app's perspective, only one state matters - Published. SDK docs ↗
# CMS editorial state machine
#
# ┌─────────┐ Edit ┌────────────┐ Request review ┌───────────┐
# │ (none) │ ────────> │ Draft │ ──────────────> │ In Review │
# └─────────┘ └────────────┘ └───────────┘
# ↑ Reject │ Approve
# └──────────────────────────────┘
# ↓
# ┌──────────────┐ startPublish date ┌───────────────┐
# │ Scheduled │ <──────────────────── │ Approved │
# └──────────────┘ (future date set) └───────────────┘
# │ │ Publish now
# │ Date arrives ↓
# └─────────────────────────────> ┌───────────────┐
# │ Published │
# └───────────────┘
# │
# stopPublish │ date arrives
# ↓
# ┌─────────────┐
# │ Expired │
# └─────────────┘
#
# Only Published items are visible in Optimizely Graph queries.
# Draft, In Review, Approved, and Scheduled items are invisible to public queries.
# Expired items are also removed from Graph results.Editor is working on it. Not visible in Graph. Can be previewed with a preview token.
Awaiting editorial approval. Not visible in Graph. Can be previewed. URL returns 404 to public visitors.
Approved with a future startPublish date. Not visible in Graph yet. Automatically publishes when the date arrives.
Live - appears in all Graph queries. ISR cache is warm. Webhook fires on publish to bust stale caches.
stopPublish date was reached. Automatically removed from Graph results. doc.expired webhook fires.
Scheduled publishing - startPublish & stopPublish#
Editors set startPublish to schedule a page to go live at a future date, and stopPublish to expire it automatically. Both are handled entirely by the CMS - the app receives no advance notice. Items simply appear in (or disappear from) Graph results when the dates arrive. The doc.expired webhook fires when stopPublish is reached.
// startPublish and stopPublish are built-in fields on every content type.
// Editors set them in the CMS - no custom property definition needed.
// startPublish - the item goes live on this date (status → Published)
// stopPublish - the item expires on this date (status → Expired → removed from Graph)
// In the Graph schema - these are on _IContent._metadata:
query GetPageWithSchedule {
LandingPage(limit: 5) {
items {
_metadata {
published # date the current version was last published
changed # date the content was last saved (any state)
url { default }
}
}
}
}
// Note: Graph does not expose startPublish or stopPublish in queries -
// they are CMS-side scheduling fields, not indexed delivery fields.
// The effect is that items simply appear or disappear from Graph results.// Graph only returns Published items. Other states are invisible.
//
// This means:
// - A Scheduled item won't appear in queries until its startPublish date
// - Calling notFound() is correct for Approved-but-not-yet-published items
// (they don't exist in Graph yet - the URL returning 404 is expected)
// - An Expired item disappears from results after stopPublish without any app code
//
// You do NOT need to filter by status in your queries - Graph handles it.
// Published = visible. Everything else = invisible.
// ✅ This is all you need - no status filter required:
query GetPage($url: String!) {
_Content(where: { _metadata: { url: { default: { eq: $url } } } }) {
items { ... }
}
}
// ❌ This would be wrong - "status" is not a Graph filter field:
_Content(where: { _metadata: { status: { eq: "Published" } } }) // doesn't existPreviewing scheduled content before it goes live#
Editors and reviewers need to see scheduled content before its startPublish date. The preview token issued by the CMS bypasses Graph's Published filter - it returns content in any state. Pass the token to getPreviewContent() to render the exact version that will go live.
// How to preview content before its startPublish date arrives.
//
// The CMS generates a preview token when an editor clicks "Preview" in Visual Builder.
// This token is scoped to that editor's session and bypasses the Published filter -
// it returns Draft, In Review, Approved, and Scheduled content.
//
// In the catch-all route, getContentByPath with a previewToken fetches draft content:
// src/app/preview/page.tsx
import { getPreviewContent } from "@optimizely/cms-sdk/server";
export default async function PreviewPage({ searchParams }) {
const { token, url } = searchParams;
if (!token) redirect("/");
const content = await getPreviewContent(url, { previewToken: token });
if (!content) redirect(`/en${url}`);
return <OptimizelyComponent content={content} />;
}
// An editor can preview a Scheduled item at any time before startPublish.
// The preview URL contains the token - share it with reviewers for approval.Webhook events across the lifecycle#
The CMS and Graph emit webhooks at lifecycle transition points. Use them to invalidate ISR caches on publish, bust expired content caches on stopPublish, and drive editorial workflow automation on status changes.
// Webhook events tied to the content lifecycle
// Register these endpoints in CMS Settings → Events
// CMS publish webhook (POST /api/publish):
// Fires when any content item is published - status → Published.
// Use this to invalidate ISR caches immediately on publish.
revalidatePath("/", "layout");
revalidateTag("navigation");
revalidateTag("banner");
// Graph webhook (POST /api/webhooks):
// Fires when Graph finishes indexing a change (slightly after the CMS publish).
// Use this for fine-grained revalidation when you know which path changed.
// Payload types:
// "bulk.completed" - Graph sync finished (all changes indexed)
// "doc.updated" - a single item was updated in Graph's index
// "doc.expired" - an item reached its stopPublish date and was removed
// Responding to doc.expired:
if (body.type === "doc.expired") {
const url = body.data?.url;
if (url) revalidatePath(url); // bust the ISR cache for the expired URL
revalidatePath("/", "layout"); // in case it appeared in listing pages
}// Approval workflow automation - webhook on status change
//
// Register a webhook in CMS Settings → Events → "Content Status Changed".
// Useful for: Slack notifications, CI step triggers, downstream sync.
// POST /api/content-status-changed
// Body: { contentKey, url, status, locale, updatedBy }
export async function POST(request: NextRequest) {
const { contentKey, url, status, updatedBy } = await request.json();
if (status === "InReview") {
// Notify reviewers in Slack
await notifySlack({
text: `📝 ${updatedBy} submitted ${url} for review`,
channel: "#content-review",
});
}
if (status === "Published") {
// Trigger a downstream sync (e.g. push to a CDN edge config)
await triggerEdgeSync({ contentKey, url });
}
return NextResponse.json({ received: true });
}Version history and rollback#
Every save creates a new version in the CMS. The Management API exposes the full version history for any content item - useful for rollback scripts and audit trails. Publish an older version by PATCHing it to status: "published". SDK docs ↗
// Content versioning - every save creates a new version.
// The Management API exposes version history for a content item.
const token = await getManagementToken();
const ENDPOINT = "https://api.cms.optimizely.com/v1/content";
// List all versions of an item:
const res = await fetch(`${ENDPOINT}/${key}/versions`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const versions = await res.json();
// versions = [{ version: 3, status: "published", created: "...", ... }, ...]
// Publish a specific older version (rollback):
await fetch(`${ENDPOINT}/${key}/versions/${version}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/merge-patch+json",
},
body: JSON.stringify({ locale: "en", status: "published" }),
cache: "no-store",
});Key Things to Know#
- →Graph only returns Published items. Draft, Scheduled, In Review, Approved, and Expired content is invisible to public queries. No status filter needed - the CMS handles it.
- →startPublish and stopPublish are CMS-side fields. They are not queryable in Graph. Items simply appear or disappear from results when the dates arrive.
- →A Scheduled item returns 404 until its startPublish date. The URL doesn't exist in Graph yet. This is expected - don't treat it as an error.
- →The preview token bypasses the Published filter. It returns Draft, Scheduled, Approved content - use it to let editors review before going live.
- →doc.expired webhook fires when stopPublish is reached. Handle it by calling revalidatePath() for the expired URL to remove it from ISR caches.
- →Every save creates a version. The Management API lets you list versions and publish an older one - useful for quick rollbacks without re-editing in the CMS UI.