Optimizely SaaS CMS Management API: The Write Side of Headless

That’s the Management API. It’s the write side of Optimizely SaaS CMS — and it doesn’t get talked about nearly enough.

Graph = read. Management API = write.

Before getting into code, the mental model that made this click for me:

Optimizely GraphManagement API
DirectionReadWrite
ProtocolGraphQLREST (JSON)
Authepi-single <GRAPH_SINGLE_KEY>Bearer token (OAuth2)
CachingCDN-cached, ISR-friendlyNo caching — always authoritative
Use forPage rendering, search, navigationContent creation, update, publish, delete

They talk to the same underlying data store. What you write via the Management API eventually shows up in Graph — after a short sync delay (usually under 10 seconds).

Step 1: Get a token

The Management API uses OAuth2 client credentials. You’ll need a Client ID and Client Secret — create these in CMS Settings → API Clients. I store mine as OPTIMIZELY_CMS_CLIENT_ID and OPTIMIZELY_CMS_CLIENT_SECRET in my env vars, consistent with the rest of the SDK setup.

Rather than re-authenticating on every API call, I cache the token in memory until 30 seconds before it expires. Handy for seed scripts that make dozens of requests in one run:

// src/lib/management-token.ts
let tokenCache: { token: string; expiresAt: number } | null = null
export async function getManagementToken(): Promise<string> {
const now = Date.now()
if (tokenCache && tokenCache.expiresAt > now + 30_000) {
return tokenCache.token
}
const res = await fetch('https://api.cms.optimizely.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: process.env.OPTIMIZELY_CMS_CLIENT_ID!,
client_secret: process.env.OPTIMIZELY_CMS_CLIENT_SECRET!,
}),
cache: 'no-store',
})
const data = await res.json()
tokenCache = { token: data.access_token, expiresAt: now + data.expires_in * 1000 }
return tokenCache.token
}

Step 2: Register a content type at runtime

You already know about contentType() + cms-cli config push for defining content types in code. But you can also register types via the Management API — useful when types are generated dynamically, or when you’re building a migration script that needs to create the schema before creating the content.

PUT /api/content/v3/types is idempotent. Running it twice with the same payload is safe — it won’t reset existing content or create duplicates. That makes it CI/CD-friendly.

I’m building a developer resource hub — think blog posts, author profiles, and topic pages. Here’s how I’d register an AuthorBlock component type programmatically:

// scripts/register-types.ts
const CMS_URL = process.env.OPTIMIZELY_CMS_URL! // e.g. https://app-abc123.cms.optimizely.com
const token = await getManagementToken()
await fetch(`${CMS_URL}/api/content/v3/types`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: 'AuthorBlock',
displayName: 'Author Block',
baseType: '_component',
properties: {
fullName: { type: 'string', displayName: 'Full Name' },
bio: { type: 'richText', displayName: 'Bio' },
avatarUrl: { type: 'string', displayName: 'Avatar URL' },
twitterHandle: { type: 'string', displayName: 'Twitter Handle' },
},
}),
})

After this runs, AuthorBlock appears in Visual Builder and is queryable in Graph after a Delta Sync.

Step 3: Create and publish a content item

POST /v1/content creates a new content item. Add "status": "published" to the version body to publish immediately. Omit it for a draft. I almost always publish immediately in seed and migration scripts — you rarely want drafts in automation.

Here I’m creating a BlogPost page and linking an author block in a content area:

// POST https://api.cms.optimizely.com/v1/content
const token = await getManagementToken()
const ENDPOINT = 'https://api.cms.optimizely.com/v1/content'
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
contentType: ['BlogPost'],
container: process.env.BLOG_ROOT_KEY, // the CMS container key for your blog section
locale: 'en',
status: 'published',
displayName: 'Getting Started with Optimizely Graph',
routeSegment: 'getting-started-with-optimizely-graph',
properties: {
heading: 'Getting Started with Optimizely Graph',
subheading: 'A practical guide to querying content from your Next.js app',
body: '<p>Optimizely Graph is the read layer...</p>',
publishDate: '2026-08-12T09:00:00Z',
// Content area — MUST use the { reference } format
authorSection: [
{ reference: 'cms://content/author-kiran-patil-key' },
],
},
}),
cache: 'no-store',
})
const { key } = await res.json()
console.log('Created blog post with key:', key)

⚠️ Gotcha: Content area reference format

Content area items must use the { reference: "cms://content/<key>" } format. Using a plain key string or { key: "..." } returns a 400 error: “A content component must have either reference or contentType set”. Spent more time on this than I’d like to admit.

Step 4: Update and publish

To update an existing item, PATCH by its key. Send only the fields you want to change — it’s a merge patch, not a full replace. Two flows here: one-step (update + publish together) or two-step (update first, then transition the version status separately).

One-step (simpler for scripts):

// Update a blog post heading and re-publish in one call
await fetch(`${ENDPOINT}/${key}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify({
locale: 'en',
status: 'published',
properties: {
heading: 'Getting Started with Optimizely Graph (Updated)',
},
}),
cache: 'no-store',
})

Two-step (for approval workflows):

// Step 1: Update the draft version
await fetch(`${ENDPOINT}/${key}/versions/${versionId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify({ locale: 'en', properties: { heading: 'Updated heading' } }),
})
// Step 2: Mark as ready → then publish
await fetch(`${ENDPOINT}/${key}/versions/${versionId}:ready`, { method: 'POST',
headers: { Authorization: `Bearer ${token}` } })
await fetch(`${ENDPOINT}/${key}/versions/${versionId}:publish`, { method: 'POST',
headers: { Authorization: `Bearer ${token}` } })

💡 Tip: You cannot change status via a PATCH on /v1/content/{key} alone — status transitions have dedicated endpoints (:ready, :publish, :draft). The one-step shortcut above works because it combines the version PATCH + status in the right way for simple automation, but for anything with approval workflows, go two-step.

Gotchas

Graph sync is not instant. After a write via the Management API, there’s a short delay — usually under 10 seconds — before the content is visible in Graph queries. Don’t immediately fire a Graph query after a Management API write in the same script without a brief wait or retry.

Never use the Management API in your Next.js render path. It’s a write API with no caching. Calling it from getStaticProps, server components, or middleware will hurt your performance and is the wrong pattern. Graph handles reads. Management API handles writes in scripts, background jobs, and webhooks.

Content area items require the cms://content/ URI format. Covered above but worth repeating because the error message isn’t obvious.

Deletes are soft by default. DELETE /v1/content/{key} does a soft delete — the item stays read-only for a configurable period. If you want immediate permanent removal (e.g. in test teardown), add the cms-permanent-delete: true header. Use this carefully in non-test environments.

Token caching matters at scale. If your migration script makes 500 API calls and you re-authenticate on each one, you’ll hit OAuth rate limits. Cache the token in memory with a 30-second expiry buffer (see Step 1 above).

Quick reference

OperationEndpointMethod
OAuth tokenapi.cms.optimizely.com/oauth/tokenPOST
Register/update content type{CMS_URL}/api/content/v3/typesPUT (idempotent)
Create content itemapi.cms.optimizely.com/v1/contentPOST
Update content itemapi.cms.optimizely.com/v1/content/{key}PATCH
Mark version ready/v1/content/{key}/versions/{v}:readyPOST
Publish version/v1/content/{key}/versions/{v}:publishPOST
Schedule publish/v1/content/{key}/versions/{v}:publish + delayUntilPOST
Delete contentapi.cms.optimizely.com/v1/content/{key}DELETE
Restore deletedapi.cms.optimizely.com/v1/content/{key}:undeletePOST

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.