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 Graph | Management API | |
|---|---|---|
| Direction | Read | Write |
| Protocol | GraphQL | REST (JSON) |
| Auth | epi-single <GRAPH_SINGLE_KEY> | Bearer token (OAuth2) |
| Caching | CDN-cached, ISR-friendly | No caching — always authoritative |
| Use for | Page rendering, search, navigation | Content 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.tslet tokenCache: { token: string; expiresAt: number } | null = nullexport 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.tsconst CMS_URL = process.env.OPTIMIZELY_CMS_URL! // e.g. https://app-abc123.cms.optimizely.comconst 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/contentconst 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 callawait 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 versionawait 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 publishawait 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
| Operation | Endpoint | Method |
|---|---|---|
| OAuth token | api.cms.optimizely.com/oauth/token | POST |
| Register/update content type | {CMS_URL}/api/content/v3/types | PUT (idempotent) |
| Create content item | api.cms.optimizely.com/v1/content | POST |
| Update content item | api.cms.optimizely.com/v1/content/{key} | PATCH |
| Mark version ready | /v1/content/{key}/versions/{v}:ready | POST |
| Publish version | /v1/content/{key}/versions/{v}:publish | POST |
| Schedule publish | /v1/content/{key}/versions/{v}:publish + delayUntil | POST |
| Delete content | api.cms.optimizely.com/v1/content/{key} | DELETE |
| Restore deleted | api.cms.optimizely.com/v1/content/{key}:undelete | POST |