Developer Demo
Management API & Content Seeding
The write side of Optimizely CMS - creating content types and items programmatically via the Management API. Used for migrations, seed scripts, and CI/CD automation.
Two API surfaces - read vs. write#
Optimizely SaaS CMS exposes two distinct API surfaces. Optimizely Graph is the GraphQL read API used by the Next.js app to deliver content to visitors - CDN-cached, efficient, and read-only. The Management API is the REST write API used by developers and scripts to create, update, and publish content - never cached, always authoritative. They talk to the same underlying data store.
# The two API surfaces in Optimizely SaaS CMS
#
# ┌─────────────────────────────────────────────────────────┐
# │ Optimizely Graph (GraphQL read API) │
# │ Endpoint: https://cg.optimizely.com/content/v2 │
# │ Auth: epi-single <GRAPH_SINGLE_KEY> │
# │ Caching: CDN-cached, ISR-friendly │
# │ Purpose: Content delivery - what your visitors see │
# └─────────────────────────────────────────────────────────┘
#
# ┌─────────────────────────────────────────────────────────┐
# │ Management API (REST write API) │
# │ Endpoint: https://<your-cms>.cms.optimizely.com │
# │ /preview3/experimental/content │
# │ Auth: OAuth2 Bearer token (client_credentials) │
# │ Caching: None - always authoritative │
# │ Purpose: Content creation, update, publish, delete │
# └─────────────────────────────────────────────────────────┘
#
# Graph = read. Management API = write.
# They talk to the same CMS data - Graph reflects what the Management API creates.
# After writing via the Management API, wait for Graph to sync (usually <10s).Optimizely Graph
Auth: epi-single <GRAPH_SINGLE_KEY>
Format: GraphQL
Caching: CDN-cached, ISR-friendly
Use for: Content delivery - page rendering, search, navigation
Management API
Auth: Bearer <OAuth2 token>
Format: REST JSON / NdJSON
Caching: No caching - always fresh
Use for: Content creation, update, publish, delete, type registration
Authentication - getManagementToken()#
The Management API uses OAuth2 client credentials. Create an API client in CMS Settings to get a CLIENT_ID and CLIENT_SECRET. The getManagementToken() helper in this project exchanges those credentials for a Bearer token and caches it in memory with a 30-second expiry buffer - so scripts that make many requests don't re-authenticate on every call.
// src/lib/optimizely/auth.ts
//
// getManagementToken() obtains an OAuth2 client_credentials token
// and caches it in memory until 30s before it expires.
//
// Required env vars:
// OPTIMIZELY_CMS_CLIENT_ID - from CMS Settings > API Clients
// OPTIMIZELY_CMS_CLIENT_SECRET - from CMS Settings > API Clients
const token = await getManagementToken();
// Use it as a Bearer token in fetch calls to the Management API:
const res = await fetch(`${CONTENT_ENDPOINT}/my-key`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
cache: "no-store",
});
// CONTENT_ENDPOINT:
const CONTENT_ENDPOINT =
`${process.env.OPTIMIZELY_CMS_URL}/preview3/experimental/content`;Creating content types at runtime#
Content types can be registered via the Management API instead of opti:push. The PUT /api/content/v3/types endpoint is idempotent - running it twice with the same payload is safe. This is useful when types are generated dynamically from an external schema (e.g., Stripe product catalog → CMS content type, Shopify collection → CMS block). SDK docs ↗
// Create or update a content type via the Management API.
// PUT is idempotent - safe to re-run in CI/CD pipelines.
//
// After this call, the type is available in Visual Builder and in Graph.
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: "QuoteBlock",
displayName: "Quote Block",
baseType: "component", // or "_page", "_experience", etc.
properties: {
quote: { type: "string", displayName: "Quote text" },
authorName: { type: "string", displayName: "Author name" },
authorRole: { type: "string", displayName: "Author role" },
},
}),
});
// Equivalent to using contentType() + opti:push, but runs at runtime -
// useful when types are generated dynamically from an external schema.Creating and publishing content items#
POST a JSON body to /preview3/experimental/content to create a new content item. Include status: "published" to publish immediately, or omit it to create a draft. Content area items must use the { reference: "cms://content/key" } format - plain key strings or { key: "..." } will error. SDK docs ↗
// Create a content item via the Management API.
// POST to /preview3/experimental/content
//
// The body is a plain JSON object with a "locale" key and the content properties.
// Use the { reference: "cms://content/<key>" } format for content area items.
const token = await getManagementToken();
const ENDPOINT = `${process.env.OPTIMIZELY_CMS_URL}/preview3/experimental/content`;
const res = await fetch(ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
contentType: ["LandingPage"],
locale: "en",
status: "published", // omit to create a draft
name: "Services",
routeSegment: "services", // sets the URL segment
properties: {
headline: "Our Services",
subheadline: "Banking built around you",
// Content area - items must use the { reference } format:
contentBlocks: [
{ reference: "cms://content/hero-block-key-abc" },
{ reference: "cms://content/faq-block-key-xyz" },
],
},
}),
cache: "no-store",
});
const { key } = await res.json(); // key = "abc123" - the new item's CMS key// Updating and publishing an existing content item.
// PATCH the item by key - only send the fields you want to change.
const token = await getManagementToken();
await fetch(`${CONTENT_ENDPOINT}/${key}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/merge-patch+json", // ← merge-patch, not full replace
},
body: JSON.stringify({
locale: "en",
status: "published", // publish immediately after patching
properties: {
headline: "Updated headline",
},
}),
cache: "no-store",
});
// Publish a draft item separately (two-step):
await fetch(`${CONTENT_ENDPOINT}/${key}/versions/${version}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/merge-patch+json" },
body: JSON.stringify({ locale: "en", status: "published" }),
});Bulk import via NdJSON (Content Source API)#
The Content Source API is a different endpoint from the Management API - it accepts NdJSON (newline-delimited JSON) for bulk syncing external data directly into Graph. Use it for data that originates outside the CMS (product reviews, quotes, third-party articles). Each line must be a valid JSON object - no trailing commas, no arrays, never pretty-printed.
// Bulk import via NdJSON - POST /api/content/v2/data
// Each line is one JSON object. No trailing commas, no arrays, no pretty-printing.
//
// This is the Content Source API format - used for external data (quotes, products, etc.)
// NOT the Management API for CMS pages. Two different endpoints.
// Build the NdJSON payload:
const items = [
{ locale: "en", key: "quote-1", properties: { quote: "Great rates.", authorName: "Alice" } },
{ locale: "en", key: "quote-2", properties: { quote: "Easy setup.", authorName: "Bob" } },
];
const ndjson = items.map((i) => JSON.stringify(i)).join("\n"); // ← never pretty-print
await fetch(`https://cg.optimizely.com/api/content/v2/data`, {
method: "POST",
headers: {
Authorization: `Basic ${btoa(`${GRAPH_APP_KEY}:`)}`, // note trailing colon
"Content-Type": "application/x-ndjson",
},
body: ndjson,
cache: "no-store",
});
// ✓ After sync (~5s), items appear in Graph:
// { QuoteBlock { items { quote authorName } } }Management API
/preview3/experimental/content
CMS-managed pages, blocks, experiences. Full editorial lifecycle.
Auth: OAuth2 Bearer
Content Source API
/api/content/v2/data
External data synced into Graph. Queryable alongside CMS content.
Auth: Basic (GRAPH_APP_KEY:)
opti:push (CLI)
npm run opti:push
Content type registration from TypeScript definitions. Run in CI.
Auth: CLIENT_ID + CLIENT_SECRET env vars
When to use the Management API#
Data migration from a legacy CMS
Export content from the old CMS, transform it to the new schema, POST each item via the Management API. Use the NdJSON bulk endpoint for large datasets.
CI/CD seed scripts
Run seed scripts on each deploy to ensure required content items (global settings, navigation roots, demo pages) exist and are published. PUT is idempotent - safe to re-run.
Auto-page creation from external events
A new product is created in an e-commerce platform → webhook triggers a script that creates a matching CMS page via the Management API → page is live in seconds.
Programmatic CMS variation updates
Once a CMS variation is created in Visual Builder, PATCH its version with a new composition and publish it - enabling automated A/B content setup. See CLAUDE.md for the full workflow.
Test data seeding
Create repeatable test content for E2E tests. DELETE it after the test suite runs. Keeps the CMS clean and tests deterministic.
Content type scaffolding
Generate CMS content types from an external schema (OpenAPI, Prisma, Contentful) by calling PUT /api/content/v3/types from a codegen script.
Reseed this CMS instance#
Use this to seed a fresh CMS instance or reseed an existing one without leaving the browser. It runs the full seed orchestration (npx tsx scripts/seed-runner.ts) on the server and streams its output live. Pick a stored instance from the dropdown to have its credentials resolved server-side from .env.local, or choose no instance to enter values manually - manual fields left blank fall back to the values in .env.local. The client ID and secret must be a content API key with write access (Settings → API Keys) - CLI-only credentials fail at the config push and content creation steps. Available in local development only; the API route returns 403 in production builds.
Key Things to Know#
- →Graph = read, Management API = write. Use Graph for everything your Next.js app serves to visitors. Use the Management API only in scripts, webhooks, and migrations - never in the page render path.
- →getManagementToken() caches the OAuth2 token in memory. Call it at the top of every Management API script - it re-authenticates only when the cached token is about to expire.
- →Content area items must use { reference: "cms://content/key" }. Plain key strings or { key: "..." } formats will return a 400 error: "A content component must have either reference or contentType set".
- →PUT /api/content/v3/types is idempotent. Run type registration in CI - re-running with the same payload is safe and doesn't reset existing content.
- →After writing via the Management API, wait for Graph to sync. There is a short delay (usually <10s) before new or updated content is visible in Graph queries.
- →NdJSON (Content Source API) is for external data, not CMS pages. Use the Management API for pages and blocks that editors own. Use Content Source API for data that originates in external systems and is synced in bulk.
Source files1 file
interface TokenCache {
token: string;
expiresAt: number;
}
interface OAuthTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
scope: string;
}
let tokenCache: TokenCache | null = null;
const EXPIRY_BUFFER_MS = 30_000;
const TOKEN_ENDPOINT = "https://api.cms.optimizely.com/oauth/token";
export async function getManagementToken(): Promise<string> {
const now = Date.now();
if (tokenCache && tokenCache.expiresAt > now + EXPIRY_BUFFER_MS) {
return tokenCache.token;
}
const clientId = process.env.OPTIMIZELY_CMS_CLIENT_ID;
const clientSecret = process.env.OPTIMIZELY_CMS_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error(
"OPTIMIZELY_CMS_CLIENT_ID and OPTIMIZELY_CMS_CLIENT_SECRET must be set for management API calls"
);
}
const response = await fetch(TOKEN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
}),
cache: "no-store",
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Failed to obtain Optimizely management token: ${response.status} ${errorBody}`
);
}
const data: OAuthTokenResponse = await response.json();
tokenCache = {
token: data.access_token,
expiresAt: now + data.expires_in * 1000,
};
return tokenCache.token;
}