Developer Demo

Contracts, Mappings & Bindings

Three CMS-level features for sharing property definitions across types, connecting types so their data stays in sync, and querying all implementations together in Graph.

ContractsMappingsBindings

Contracts

Like a TypeScript interface. Declare shared fields (SEO metadata, authoring dates, categories) and apply the contract to any content type. Every implementing type gets those fields in the editor and in Graph.

Bindings

A template that says ArticlePage feeds data into ArticleTeaser. Once defined, you apply the binding to actual content instances so their properties stay synchronized automatically.

Mappings

The field-level connections inside a binding: teaserImage: { from: "hero.image" }. Use dot notation to reach into nested component properties. Type compatibility rules determine which source/target combinations are valid.

Contracts #

A contract defines a named set of properties that content types can implement, analogous to an interface in TypeScript or Java. The CMS enforces that every implementing type exposes those fields. Optimizely Graph generates a shared interface type (e.g. IEditorialContent) so you can query all implementing types in one request instead of one query per type.

Defining a contract #

In this project, ArticlePage and CaseStudyPage both need title, summary, heroImage, and tags. Instead of duplicating those definitions, they live in a single EditorialContentContract defined with contract() in optimizely.config.mjs.

optimizely.config.mjs
// optimizely.config.mjs
//
// Properties shared by ArticlePage and CaseStudyPage, defined once.
// contract() is exported by @optimizely/cms-sdk since 2.1.0.

import { contract } from "@optimizely/cms-sdk";

export const EditorialContentContract = contract({
  key: "EditorialContent",
  displayName: "Editorial Content",
  properties: {
    title:     { type: "string",           displayName: "Title",     indexingType: "searchable", isLocalized: true },
    summary:   { type: "string",           displayName: "Summary",   indexingType: "searchable", isLocalized: true },
    heroImage: { type: "contentReference", displayName: "Hero Image", allowedTypes: ["_image"] },
    tags:      { type: "array",            displayName: "Tags",      indexingType: "queryable",  items: { type: "string" } },
  },
});

SDK version

contract() requires @optimizely/cms-sdk 2.1.0 or later. On 2.0.0 the import fails at runtime - there, fall back to defining the shared properties as a plain object and spreading it into each type's properties block.

Implementing the contract in content types #

Each type lists the contracts it implements in extends, then adds its own type-specific fields in properties. A type can extend a single contract or an array of them.

optimizely.config.mjs
// optimizely.config.mjs
//
// Both types declare  extends: [SEOContract, EditorialContentContract].
// No duplicated field definitions - contentType() merges the contract
// properties into the type at definition time.

export const ArticlePageType = contentType({
  key: "ArticlePage",
  baseType: "_page",
  extends: [SEOContract, EditorialContentContract],
  properties: {
    body:        { type: "richText",         displayName: "Body",         indexingType: "searchable", isLocalized: true },
    author:      { type: "contentReference", displayName: "Author",       allowedTypes: ["AuthorBlock"] },
    publishDate: { type: "dateTime",         displayName: "Publish Date", indexingType: "queryable" },
    category:    { type: "string",           displayName: "Category",     indexingType: "queryable", enum: CATEGORY_ENUM },
    relatedArticles: { type: "array", items: { type: "contentReference", allowedTypes: ["ArticlePage"] } },
  },
});

export const CaseStudyPageType = contentType({
  key: "CaseStudyPage",
  baseType: "_page",
  extends: [SEOContract, EditorialContentContract],
  properties: {
    clientName: { type: "string",   displayName: "Client Name", indexingType: "queryable", isLocalized: true },
    industry:   { type: "string",   displayName: "Industry",    indexingType: "queryable", enum: CATEGORY_ENUM },
    challenge:  { type: "richText", displayName: "Challenge",   indexingType: "searchable", isLocalized: true },
    solution:   { type: "richText", displayName: "Solution",    indexingType: "searchable", isLocalized: true },
    // ...outcomes, testimonial, relatedCaseStudies
  },
});

Registration #

src/lib/optimizely/componentRegistry.ts
// src/lib/optimizely/componentRegistry.ts
//
// Contracts do NOT need to be registered. contentType() merges the
// contract's properties into the type object at definition time, so the
// registry (and the SDK's generated Graph fragments) see the full
// property set on each type.

initContentTypeRegistry([
  ArticlePageType,            // ← extends [SEOContract, EditorialContentContract]
  CaseStudyPageType,          // ← extends [SEOContract, EditorialContentContract]
  // ...other types
]);

No separate registration

Only content types go in initContentTypeRegistry. Because contentType() merges contract properties into the type object when it is defined, the registry and the SDK's query builder already see every inherited field - no ordering constraints, no contract entries.

Unified Graph interface queries #

Once the EditorialContent contract exists in the CMS, Graph generates an IEditorialContent interface type. The query below compares what you write before the contract (two root fields, merged in code) versus after (one interface field, sorted by Graph).

Before: two queries merged in code
# Without a contract: two separate queries, merged in application code.
# src/lib/graphql/queries/GetEditorialContent.ts

query GetEditorialContent($limit: Int) {
  ArticlePage(
    limit: $limit
    orderBy: { _metadata: { published: DESC } }
    where: { _metadata: { url: { default: { exist: true } } } }
  ) {
    items {
      title summary tags
      _metadata { published url { default } __typename }
    }
  }
  CaseStudyPage(
    limit: $limit
    orderBy: { _metadata: { published: DESC } }
    where: { _metadata: { url: { default: { exist: true } } } }
  ) {
    items {
      title summary tags
      _metadata { published url { default } __typename }
    }
  }
}
# Then merge + sort in code: [...articles, ...caseStudies].sort(byDate)
After: one interface query
# With a contract: one query, sorted by Graph, no merging in code.
# Requires the EditorialContent contract to be created in the CMS first.

query GetEditorialContent($limit: Int) {
  IEditorialContent(
    limit: $limit
    orderBy: { _metadata: { published: DESC } }
    where: { _metadata: { url: { default: { exist: true } } } }
  ) {
    items {
      title
      summary
      tags
      _metadata { published url { default } __typename }
      ... on ArticlePage  { category }
      ... on CaseStudyPage { clientName industry }
    }
  }
}

Live demo: editorial content feed #

The cards below are fetched from ArticlePage and CaseStudyPage in a single GraphQL request and merged by publish date, matching what the IEditorialContent interface query would return once the contract is active in the CMS.

Bindings & Mappings #

A content type binding is a template that declares which source type feeds data into which target type. Mappings are the individual field-to-field connections inside that template. Defining the binding is a one-time setup step; you then apply it to specific content instances to activate the sync (see the next section).

Creating a binding definition #

POST to https://api.cms.optimizely.com/v1/contenttypebindings with the source type (from), target type (to), and the property mappings. This is typically done once in a seeding script.

scripts/seed-bindings.ts
const token = await getManagementToken(); // OAuth2 client_credentials

await fetch("https://api.cms.optimizely.com/v1/contenttypebindings", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    key: "ArticleTeaserBinding",   // unique identifier for this binding definition
    from: "ArticlePage",           // source content type
    to: "ArticleTeaser",           // target content type

    // propertyMappings: keys are target property names, values reference source
    // properties. Use dot notation to reach into nested component properties.
    propertyMappings: {
      teaserTitle:   { from: "title" },
      teaserSummary: { from: "summary" },
      teaserImage:   { from: "heroImage" },        // top-level property
      teaserAuthor:  { from: "metadata.author" },  // nested via dot notation
    },
  }),
});

Reading and updating bindings #

REST API: binding management
// List all bindings:
GET  https://api.cms.optimizely.com/v1/contenttypebindings

// Get one binding:
GET  https://api.cms.optimizely.com/v1/contenttypebindings/ArticleTeaserBinding

// Partial update (only send changed mappings):
PATCH https://api.cms.optimizely.com/v1/contenttypebindings/ArticleTeaserBinding
{
  "propertyMappings": {
    "teaserImage": { "from": "hero.image" }
  }
}

// Delete:
DELETE https://api.cms.optimizely.com/v1/contenttypebindings/ArticleTeaserBinding

Mapping type compatibility #

Not every source/target property type combination is valid. The compatibility table uses CMS-internal type names; the comment block maps them to their SDK equivalents.

Property type compatibility
// These are CMS-internal type names (not SDK "type:" values).
// Most pairs must match exactly; ShortString has some flexibility:
//
// ShortString → ShortString, LongString, XHTMLString, LinkItem, URL  ✓
// LongString  → LongString, XHTMLString                               ✓
// Integer     → Integer only                                           ✓
// DateTime    → DateTime only                                          ✓
// ContentRef  → ContentRef (same allowedTypes)                         ✓
//
// SDK type mapping:
//   string    ≈ ShortString
//   richText  ≈ XHTMLString / LongString
//   url       ≈ URL / LinkItem
//   integer   ≈ Integer
//   dateTime  ≈ DateTime
//   contentReference ≈ ContentRef

Applying Bindings to Content Instances #

Once the binding template exists, apply it to actual content by including a binding object in the create or PATCH body. Reference the template by key, and point source at the content instance to pull data from.

Binding a top-level content item #

scripts/bind-content.ts
const token = await getManagementToken();

// Create a new ArticleTeaser bound to an existing ArticlePage:
await fetch("https://api.cms.optimizely.com/v1/content", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    contentType: "ArticleTeaser",
    initialVersion: {
      locale: "en",
      displayName: "Article Teaser (Bound)",
      binding: {
        contentTypeBinding: "ArticleTeaserBinding",
        source: "cms://content/256c585881ad452dbd6df90eafadb137?loc=en",
      },
    },
  }),
});

// The teaser's teaserTitle / teaserSummary / teaserImage are now populated
// from the linked ArticlePage and stay in sync on every publish.

Source URI format

source uses the CMS permanent URI: cms://content/<contentKey>?loc=<locale>. Use the content key (not the route segment) so the reference survives URL changes. Locale is required because properties are stored per locale.

Binding a component property inside a page #

Bindings can target a single component-type property rather than the whole content item, useful when only one slot on a page should pull from an external source.

Bind a component property
// To bind a single component property rather than the whole item,
// nest the binding inside the property value:

await fetch("https://api.cms.optimizely.com/v1/content", {
  method: "POST",
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    contentType: "LandingPage",
    initialVersion: {
      locale: "en",
      displayName: "Landing Page",
      properties: {
        featuredTeaser: {
          value: {
            binding: {
              contentTypeBinding: "ArticleTeaserBinding",
              source: "cms://content/256c585881ad452dbd6df90eafadb137?loc=en",
            },
          },
        },
      },
    },
  }),
});

Bindings vs. contentReference

A contentReference stores a pointer: the editor picks any item and the frontend fetches it at render time. A binding copies mapped property values directly into the target at publish time. Use a contentReference when the editor should choose freely; use a binding when the target should always mirror specific fields from a specific source.

When to use each #

ScenarioUse
Multiple page types share SEO, category, or date fieldsContract
Query all pages of different types in one Graph requestContract → interface query
A teaser block should always reflect its parent article's title/imageBinding + Mappings
Editors need to pick any item from a list of contentcontentReference property
External product catalog should sync into CMS content typesContent Source API (see External Content demo)
One type's data should populate a different type's nested componentBinding on a component property