Reference

SDK Reference

Content type options, copy-paste examples, the SDK API surface, and where to find the generated GraphQL query when something breaks.

Setup #

install and file layout
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()
cms-cli
# 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-types
the registry: runs once, before any render
import { 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() #

baseTypeExtra keys it accepts
_pagemayContainTypes
_experiencemayContainTypes, composition, composition properties
_componentcompositionBehaviors, mayContainTypes
_image / _media / _videonone
_foldermayContainTypes. Not in Graph.
_sectionSDK internal. Never declare one.
KeyNotes
keyRequired. The Graph type name. A deleted key stays reserved.
displayNameRequired.
descriptionEditor help text.
baseTypeRequired. Decides which keys on the left are legal.
extendsContract or array of contracts.
propertiesSee below.
mayContainTypesChildren in the content tree, NOT what a content area accepts.
compositionBehaviorssectionEnabled | 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 #

typeGraph returnsOptions only this type has
stringStringpattern, minLength, maxLength, enum
richText{ json }, or { html } on requesteditorSettings.preset
url{ default, hierarchical }none
linkLink object: href, text, target, titlenone
integerIntminimum, maximum, enum
floatFloatminimum, maximum, enum
booleanBooleannone
dateTimeISO stringminimum, maximum
jsonParsed value. Never indexed, never filterable.none
binaryBinary payloadnone. Media goes through _image / _media / _video types.
arrayArray of the item typeitems, minItems, maxItems. Items cannot be arrays.
contentThe full typed item, inline expandedcontentType | allowedTypes | restrictedTypes
contentReference{ key, url } onlycontentType | allowedTypes | restrictedTypes
componentEmbedded value, no key of its owncontentType (required)
compositionCompositionStructureNodeformat (required), allowedTypes. _experience only.
every property type on one type
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 #

SettingNotes
displayNameEditor label. Without it the editor sees the raw key.
descriptionHelp text under the field.
isRequiredBlocks publishing while empty.
isLocalizedOne value per locale. Prose only, never URLs or enum keys.
sortOrderEditor panel order.
groupEditor section. Built in: Content, Scheduling, Settings, Shortcut, Categories, DynamicBlocks. Anything else must be in propertyGroups.
displayModeavailable (default) | hidden. Hidden stays in the schema and in Graph.
formatEditor hint, e.g. selectOne on a string enum, outline on a composition.
indexingTypesearchable | queryable | disabled.
Reference constraintUse when
contentType: XTypeAlways 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 #

element block
// 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" },
  },
});
section block with a content area
// 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] },
    },
  },
});
contract + extends
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.
experience with extra compositions
// 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"] },
});
display template
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-sdkWhat 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() / GraphClientConfigure 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 / isContentTypeRegisteredGuards, handy for auto-discovering your own modules.
BlankExperienceContentType / BlankSectionContentTypeBuilt-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.
GraphErrorsNamespace 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 / BuildConfigType namespaces for the definition shapes.
/react/serverWhat 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.
OptimizelyComponentRenders one content item through the registry.
OptimizelyCompositionRenders an experience node tree.
OptimizelyGridSectionRenders 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 / setContextPer-request context (preview token, locale) and the adapter API.
Other entry pointsWhat it does
/react/client: PreviewComponentListens for CMS save events. Bring your own onNavigate.
/react/nextjs: NextPreviewComponentSame, wired to the Next router.
/react/richText: RichTextRenders richText json. Plus createLinkComponent, createImageComponent, createTableComponent, generateDefaultElements, generateDefaultLeafs.
/forms/reactFormWrapper, FormStep, FormElement, useFormField, useFormValidation, createJsonSubmitHandler.
/forms/validationvalidateField, isFieldRequired, getHtmlValidationAttributes, getSelectionOptions.
/schematoSchema, Schema, SchemaValidationError.
/telemetrygetTracer, createSpan, getMeter, logError, logWarning.
/buildConfigThe buildConfig types on their own, for config files.
GraphClient methodReturnsNotes
getContentByPath(path, opts?)any[]The registry writes the query. Always an array, empty on a miss.
getContent(ref, opts?)anyBy key, or a graph:// string. Published only. version beats locale.
getPreviewContent(params, opts?)anyDraft, 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.dataYour 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.

config() in full
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" } });
the query argument surface
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 #

three ways to get the query text
// 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 classCarries
OptimizelyGraphErrorBase class for all of them.
GraphResponseErrorrequest.query, request.variables.
GraphHttpResponseErrorthe above plus status.
GraphContentResponseErrorthe above plus errors[], every GraphQL message.
GraphMissingContentTypeErrorcontentType.
GraphQueryGenerationErrorcontentType, propertyName, parentContentType.
GraphFragmentThresholdErrorcontentType, fragmentCount, threshold.
MessageCause
Cannot query field "x" on type "Y" / Unknown typeCMS 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 registryMissing from initContentTypeRegistry().
Content type is undefinedA type defined in a "use client" file, or an import cycle. Move types to a shared module.
produced N inner fragments, exceeding the limitUnconstrained 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.