Reference
SDK Reference
Setup #
npm i @optimizely/cms-sdk
npm i -D @optimizely/cms-cli
# optimizely.config.mjs buildConfig() + page/experience types + contracts
# src/components/** one contentType() + displayTemplate()s + component per file
# src/lib/registry.ts config() + initContentTypeRegistry() + initReactComponentRegistry()
# src/app/[[...slug]] getContentByPath()# Credentials are NOT read from .env files. Inject them inline.
# Do not pass --host on SaaS: the tenant comes from the credentials.
OPTIMIZELY_CMS_CLIENT_ID=x OPTIMIZELY_CMS_CLIENT_SECRET=y \
npx @optimizely/cms-cli config push optimizely.config.mjs --dryRun
npx @optimizely/cms-cli config push optimizely.config.mjs # push
npx @optimizely/cms-cli config push optimizely.config.mjs -- --force # breaking changes
npx @optimizely/cms-cli config pull --output ./src/content-types --group
npx @optimizely/cms-cli login
npx @optimizely/cms-cli config delete <file>
npx @optimizely/cms-cli danger delete-all-content-typesimport { config, initContentTypeRegistry, initDisplayTemplateRegistry,
BlankExperienceContentType, BlankSectionContentType } from "@optimizely/cms-sdk";
import { initReactComponentRegistry, initForms } from "@optimizely/cms-sdk/react/server";
config({ apiKey: process.env.GRAPH_SINGLE_KEY, graphUrl: process.env.GRAPH_GATEWAY });
initContentTypeRegistry([BlankExperienceContentType, BlankSectionContentType, ProductPageType]);
initDisplayTemplateRegistry([ProductCardTemplate]);
initReactComponentRegistry({ resolver: { ProductPage, "HeroBlock:Compact": CompactHero } });
initForms({ container, textbox, textarea, selection, submit }); // only if Forms is on
// The registry decides what the generated query can select.
// Registered but not pushed = every query fails. Pushed but not registered = invisible.Deploy order
Push the schema, wait for Graph's schema sync (roughly ten minutes), then deploy code that queries the new field. A field Graph does not know yet is a validation error, and that fails the whole query, not just that field.
contentType() #
| baseType | Extra keys it accepts |
|---|---|
| _page | mayContainTypes |
| _experience | mayContainTypes, composition, composition properties |
| _component | compositionBehaviors, mayContainTypes |
| _image / _media / _video | none |
| _folder | mayContainTypes. Not in Graph. |
| _section | SDK internal. Never declare one. |
| Key | Notes |
|---|---|
| key | Required. The Graph type name. A deleted key stays reserved. |
| displayName | Required. |
| description | Editor help text. |
| baseType | Required. Decides which keys on the left are legal. |
| extends | Contract or array of contracts. |
| properties | See below. |
| mayContainTypes | Children in the content tree, NOT what a content area accepts. |
| compositionBehaviors | sectionEnabled | elementEnabled | formsElementEnabled. |
compositionBehaviors
elementEnabled cannot have content area properties; sectionEnabled cannot sit in a grid column. Declaring both lifts neither restriction. A block with no behaviours at all is still valid: it can be the target of a component or content property, it just never shows in the block picker.
Property Types #
| type | Graph returns | Options only this type has |
|---|---|---|
| string | String | pattern, minLength, maxLength, enum |
| richText | { json }, or { html } on request | editorSettings.preset |
| url | { default, hierarchical } | none |
| link | Link object: href, text, target, title | none |
| integer | Int | minimum, maximum, enum |
| float | Float | minimum, maximum, enum |
| boolean | Boolean | none |
| dateTime | ISO string | minimum, maximum |
| json | Parsed value. Never indexed, never filterable. | none |
| binary | Binary payload | none. Media goes through _image / _media / _video types. |
| array | Array of the item type | items, minItems, maxItems. Items cannot be arrays. |
| content | The full typed item, inline expanded | contentType | allowedTypes | restrictedTypes |
| contentReference | { key, url } only | contentType | allowedTypes | restrictedTypes |
| component | Embedded value, no key of its own | contentType (required) |
| composition | CompositionStructureNode | format (required), allowedTypes. _experience only. |
import { contentType } from "@optimizely/cms-sdk";
export const ProductPageType = contentType({
key: "ProductPage",
displayName: "Product",
description: "Shown under the name in the CMS.",
baseType: "_page",
extends: [SEOContract, ComplianceContract], // contracts, single or array
mayContainTypes: ["_self", "ComparisonPage"], // children in the content TREE
properties: {
name: { type: "string", displayName: "Name", isRequired: true, isLocalized: true,
indexingType: "searchable", sortOrder: 0 },
code: { type: "string", pattern: "^P-\\d{4}$", minLength: 6, maxLength: 6,
indexingType: "queryable" },
family: { type: "string", indexingType: "queryable",
enum: [{ value: "a", displayName: "Family A" },
{ value: "b", displayName: "Family B" }] },
summary: { type: "string", maxLength: 180, isLocalized: true, indexingType: "searchable" },
body: { type: "richText", editorSettings: { preset: "expanded" }, isLocalized: true,
indexingType: "searchable" }, // preset: default | expanded | minimal
rate: { type: "float", minimum: 0, maximum: 20, indexingType: "queryable", group: "Rates" },
stock: { type: "integer", minimum: 0, maximum: 9999, indexingType: "queryable" },
notice: { type: "integer", indexingType: "queryable",
enum: [{ value: 0, displayName: "None" }, { value: 30, displayName: "30 days" }] },
featured: { type: "boolean", indexingType: "queryable" },
launchAt: { type: "dateTime", minimum: "2020-01-01T00:00:00Z", indexingType: "queryable" },
applyUrl: { type: "url" }, // plain URL / content picker
guidance: { type: "link" }, // href + text + target + title
matrix: { type: "json", isLocalized: true }, // opaque: never filterable
benefits: { type: "array", items: { type: "string", maxLength: 90 },
minItems: 3, maxItems: 6, isLocalized: true, indexingType: "searchable" },
tiers: { type: "array", minItems: 1,
items: { type: "content", allowedTypes: [TierBlockType] } }, // inline expanded
related: { type: "array", maxItems: 4,
items: { type: "contentReference", allowedTypes: ["ProductPage"] } }, // { key, url }
image: { type: "contentReference", allowedTypes: ["_image"], isRequired: true },
lead: { type: "content", contentType: AuthorBlockType },
legal: { type: "component", contentType: DisclosureComponentType }, // embedded, no key
legacy: { type: "string", displayMode: "hidden", indexingType: "queryable" },
internal: { type: "string", indexingType: "disabled" }, // editable, never indexed
},
});Property Settings #
| Setting | Notes |
|---|---|
| displayName | Editor label. Without it the editor sees the raw key. |
| description | Help text under the field. |
| isRequired | Blocks publishing while empty. |
| isLocalized | One value per locale. Prose only, never URLs or enum keys. |
| sortOrder | Editor panel order. |
| group | Editor section. Built in: Content, Scheduling, Settings, Shortcut, Categories, DynamicBlocks. Anything else must be in propertyGroups. |
| displayMode | available (default) | hidden. Hidden stays in the schema and in Graph. |
| format | Editor hint, e.g. selectOne on a string enum, outline on a composition. |
| indexingType | searchable | queryable | disabled. |
| Reference constraint | Use when |
|---|---|
| contentType: XType | Always the same type. The only form that types the field in TS. |
| allowedTypes: [...] | Allow list. Base type strings work: _image, _component, _page, _self. |
| restrictedTypes: [...] | Deny list, for an open area with a few blocks kept out. |
Exactly one constraint, never empty
Every content / contentReference property (and any array of one) must declare exactly one of the three, non-empty. Unconstrained is rejected at push time and nothing is created. Visible under --dryRun.
indexingType is for primitives
searchable and queryable are rejected on references. On a reference, omit it: disabled is accepted, but the SDK then drops the field from the generated fragment, so it arrives undefined forever.
In .tsx, pass objects
allowedTypes: ["TierBlock"] fails typecheck; allowedTypes: [TierBlockType] is correct. Base type strings are the exception. In a .mjs file plain key strings work everywhere.
Copy-Paste Examples #
// Leaf block: fits in a grid column, CANNOT have content areas.
export const TierBlockType = contentType({
key: "TierBlock",
displayName: "Tier",
baseType: "_component",
compositionBehaviors: ["elementEnabled"],
properties: {
label: { type: "string", isLocalized: true, isRequired: true },
from: { type: "integer", indexingType: "queryable" },
rate: { type: "float", indexingType: "queryable" },
},
});// Container block: CAN have content areas, cannot sit in a grid column.
export const FaqContainerType = contentType({
key: "FaqContainer",
displayName: "FAQ Container",
baseType: "_component",
compositionBehaviors: ["sectionEnabled"],
properties: {
heading: { type: "string", isLocalized: true },
items: {
type: "array",
items: { type: "content", allowedTypes: [FaqItemType] },
},
},
});import { contract } from "@optimizely/cms-sdk";
export const SEOContract = contract({
key: "SEO", // key, displayName, properties. Nothing else.
displayName: "SEO",
properties: {
metaTitle: { type: "string", isLocalized: true, group: "SEO", sortOrder: 0 },
metaDescription: { type: "string", isLocalized: true, group: "SEO", sortOrder: 1 },
ogImage: { type: "contentReference", allowedTypes: ["_image"], group: "SEO" },
},
});
// Merged at definition time, so the registry and the generated fragments see the
// full set. Export it from optimizely.config.mjs or the push never creates it.// A .mjs file: type "composition" is not in the TS typings, the runtime takes it.
export const LandingExperienceType = contentType({
key: "LandingExperience",
displayName: "Landing",
baseType: "_experience",
extends: SEOContract,
properties: {
topComposition: {
type: "composition",
format: "outline", // mandatory, immutable once it holds content
displayName: "Top (locked)",
allowedTypes: ["HeroBlock", "BlankSection"],
sortOrder: 0,
},
middleComposition: { type: "composition", format: "outline", sortOrder: 1 },
},
// The built-in composition: name reserved, always sorts last, restrict it here.
composition: { allowedTypes: ["FaqContainer"] },
});import { displayTemplate } from "@optimizely/cms-sdk";
export const ProductCardTemplate = displayTemplate({
key: "ProductCardTemplate",
displayName: "Card",
isDefault: true,
contentType: "ProductPage", // or nodeType: "row" | "column" | "section"
tag: "Card", // matches the "ProductPage:Card" resolver key
settings: {
background: {
editor: "select", // "select" | "checkbox"
displayName: "Background",
sortOrder: 0,
choices: { // choices, NOT enum. First by sortOrder is the default.
white: { displayName: "White", sortOrder: 0 },
brand: { displayName: "Blue", sortOrder: 1 },
},
},
boxed: { editor: "checkbox", displayName: "Boxed", choices: {} },
},
});What the SDK Exports #
Nine entry points. Deep imports are blocked by the package exports map, so if it is not listed here it is not reachable.
| @optimizely/cms-sdk | What it does |
|---|---|
| contentType() / contract() / displayTemplate() | Define a type, a shared property set, a template. |
| buildConfig() | Default export of optimizely.config.mjs: components glob, locale, propertyGroups, applications, content. |
| config() / getClient() / GraphClient | Configure once, then get a client anywhere. getClient(overrides) for a one-off. |
| initContentTypeRegistry() / initDisplayTemplateRegistry() | Tell the SDK which types exist. Drives query generation. |
| isContentType / isContract / isDisplayTemplate / isContentTypeRegistered | Guards, handy for auto-discovering your own modules. |
| BlankExperienceContentType / BlankSectionContentType | Built-in types. Register them or Visual Builder pages fail. |
| OptiForms*ContentType (13) | Native Forms schemas, if you register them by hand instead of initForms(). |
| damAssets(content) | getSrcset, getAlt, isDamImageAsset, isDamVideoAsset, isDamRawFileAsset. |
| GraphErrors | Namespace holding every error class. See debugging below. |
| ContentProps<typeof XType> | Type helper: component props inferred from a content type or display template. |
| ContentTypes / Properties / DisplayTemplates / BuildConfig | Type namespaces for the definition shapes. |
| /react/server | What it does |
|---|---|
| initReactComponentRegistry({ resolver }) | Map types to components. Object or function. "Type:Tag" keys for templates. |
| initForms(handlers) | Registers all ten Forms types and components in one call. |
| OptimizelyComponent | Renders one content item through the registry. |
| OptimizelyComposition | Renders an experience node tree. |
| OptimizelyGridSection | Renders a grid section, with row / column / ComponentWrapper overrides. |
| getPreviewUtils(content) | pa(prop) for on-page edit attributes, src(ref) for tokenised image URLs. |
| withAppContext / getContext / setContext | Per-request context (preview token, locale) and the adapter API. |
| Other entry points | What it does |
|---|---|
| /react/client: PreviewComponent | Listens for CMS save events. Bring your own onNavigate. |
| /react/nextjs: NextPreviewComponent | Same, wired to the Next router. |
| /react/richText: RichText | Renders richText json. Plus createLinkComponent, createImageComponent, createTableComponent, generateDefaultElements, generateDefaultLeafs. |
| /forms/react | FormWrapper, FormStep, FormElement, useFormField, useFormValidation, createJsonSubmitHandler. |
| /forms/validation | validateField, isFieldRequired, getHtmlValidationAttributes, getSelectionOptions. |
| /schema | toSchema, Schema, SchemaValidationError. |
| /telemetry | getTracer, createSpan, getMeter, logError, logWarning. |
| /buildConfig | The buildConfig types on their own, for config files. |
| GraphClient method | Returns | Notes |
|---|---|---|
| getContentByPath(path, opts?) | any[] | The registry writes the query. Always an array, empty on a miss. |
| getContent(ref, opts?) | any | By key, or a graph:// string. Published only. version beats locale. |
| getPreviewContent(params, opts?) | any | Draft, from the CMS preview params: preview_token, key, ctx, ver, loc. |
| getPath(ref, opts?) | ancestor metadata[] | Breadcrumbs, top-most first. |
| getItems(ref, opts?) | child metadata[] | Children of a page or folder. |
| request(query, vars, previewToken?, cache?, slot?, stored?) | json.data | Your own query. Returns data even when errors[] is present. |
Queries #
Every registered type becomes an inline fragment, so one getContentByPath() returns the page and every block in its composition. Write a query by hand only for lists, filters, facets, aggregates and lean projections.
import { config, getClient, GraphClient } from "@optimizely/cms-sdk";
config({
apiKey: process.env.GRAPH_SINGLE_KEY, // sent as "epi-single <key>"
graphUrl: process.env.GRAPH_GATEWAY, // https://cg.optimizely.com/content/v2
userAgent: "MyApp/1.0",
query: { // per-request defaults, overridable on any single call
cache: true, // Graph's own CDN
stored: true, // ?stored=true, lets the server reuse query plans
slot: "Current", // "New" hits the index being rebuilt during a smooth rebuild
host: undefined, // path lookups only, for one CMS serving several sites
},
fragment: { // FIXED at construction. Need different values? Separate client.
richTextFormat: "json", // json (default) | html | both. Reading .html without this = undefined
compositionDepth: 4,
expandContracts: true,
maxThreshold: 100, // fragments per content area before it throws
dam: "automatic", // automatic | on | off
typeFilter: (key) => key !== "LegacyBlock",
},
});
getClient(); // uses the global config
getClient({ query: { host: "other.example.com" } }); // one-off override
new GraphClient(apiKey, { fragment: { dam: "off" } });query Products($locale: [Locales], $phrase: String, $limit: Int, $cursor: String) {
ProductPage(
locale: $locale
variation: { include: ALL } # variations are EXCLUDED without this
where: {
_metadata: { url: { default: { exist: true } } }
family: { in: ["a", "b"] } # queryable fields only
rate: { gte: 4.5 }
launchAt: { gte: "2026-01-01T00:00:00Z" }
_fulltext: { match: $phrase, fuzzy: true } # all searchable fields
}
orderBy: { rate: DESC } # or { _metadata: { published: DESC } }
limit: $limit # caps at 100
cursor: $cursor # beats skip for deep paging
) {
total
cursor
items {
__typename
name
rate
body { json } # { html } only if richTextFormat says so
tiers { ... on TierBlock { label rate } } # content: inline expanded
related { key url { default } } # contentReference: that is all you get
_metadata { key version locale status published variation
url { default hierarchical graph } }
_itemMetadata { categories } # taxonomy lives here, NOT in _metadata
}
facets { # same where clause. limit: 0 for facets only
family(orderType: COUNT, orderBy: DESC, limit: 10) { name count }
}
}
}Deeper on each: Graph Queries, Content Listing, Search, Caching.
Seeing the Generated Query #
// 1. Log every generated query. request() is the single funnel: getContentByPath,
// getContent, getPreviewContent, getPath and getItems all route through it.
import { GraphClient } from "@optimizely/cms-sdk";
const original = GraphClient.prototype.request;
GraphClient.prototype.request = function (query, variables, ...rest) {
console.log("[graph]", query, JSON.stringify(variables));
return original.call(this, query, variables, ...rest);
};
// 2. On failure the error already carries the exact query that was sent.
import { GraphErrors } from "@optimizely/cms-sdk";
try {
await getClient().getContentByPath("/products/");
} catch (e) {
if (e instanceof GraphErrors.GraphResponseError) {
console.log(e.request.query, e.request.variables);
}
if (e instanceof GraphErrors.GraphContentResponseError) {
console.log(e.status, e.errors); // every message, not just the first
}
}
// 3. Paste that query into the Graph GraphiQL explorer to iterate on it.
// The wire call is POST <graphUrl>?cache=true&stored=true
// with Authorization: epi-single <key> (or Bearer <previewToken>).| Error class | Carries |
|---|---|
| OptimizelyGraphError | Base class for all of them. |
| GraphResponseError | request.query, request.variables. |
| GraphHttpResponseError | the above plus status. |
| GraphContentResponseError | the above plus errors[], every GraphQL message. |
| GraphMissingContentTypeError | contentType. |
| GraphQueryGenerationError | contentType, propertyName, parentContentType. |
| GraphFragmentThresholdError | contentType, fragmentCount, threshold. |
| Message | Cause |
|---|---|
| Cannot query field "x" on type "Y" / Unknown type | CMS and code out of sync. Push, then wait for the schema sync. |
| N errors in the GraphQL query. Check "errors" object. | Read error.errors[]. The message only summarises. |
| Content type "X" is not available in the component registry | Missing from initContentTypeRegistry(). |
| Content type is undefined | A type defined in a "use client" file, or an import cycle. Move types to a shared module. |
| produced N inner fragments, exceeding the limit | Unconstrained content area. Add allowedTypes, or raise fragment.maxThreshold. |
| Error when calling `fetch` | Wrong graphUrl, or the gateway is unreachable. |
Gotchas #
A 200 can still be a failure
request() returns json.data. A partial failure comes back 200 with an errors array and a null root field, and nothing throws. Branch on the empty result too.
Variations are excluded by default
Without a variation argument, _metadata.variation is always null and variation items never appear. Pass { include: ALL } and pick the match yourself.
A dropped field is undefined, not an error
A reference marked disabled, or a type excluded by typeFilter, is filtered out of the fragment and simply never arrives.
fragment options are construction-time
Nothing in the fragment group can be overridden per request. A client needing different values has to be a separate new GraphClient().
Breaking changes need --force
Adding isLocalized to an existing field, removing a property, removing an indexingType, changing a type. Push to every instance the code runs against.
Checkbox settings are strings
Display template checkboxes arrive as "True" / "False", so ds.x === true never fires.