Developer Demo
Contracts, Mappings & Bindings
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
//
// 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
//
// 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
//
// 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
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).
# 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)# 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.
Buying a second home abroad without losing money on the transfer
A family buying a holiday home in Portugal used a currency specialist transfer instead of their everyday bank's exchange rate.
1 Oct 2026
InstanceMetadataFinancing seasonal stock for a Christmas-heavy retailer
A gift retailer used a short-term seasonal overdraft to buy Christmas stock months before the revenue arrived.
1 Oct 2026
InstanceMetadataResetting a household budget after an 18-month career break
A customer returning to work after a career break rebuilt a household budget around a new income and childcare costs.
1 Oct 2026
InstanceMetadataPlanning a three-generation farm handover
A family farm worked with Mosey's agricultural team to plan a phased handover to the next generation without disrupting cash flow.
1 Oct 2026
InstanceMetadataSmoothing out a freelancer's feast-and-famine income
A freelance designer used a tax savings pot and a rolling budget to stop large invoices from vanishing into day-to-day spending.
1 Oct 2026
InstanceMetadataKeeping restricted and unrestricted funds separate - without extra admin
A local charity used sub-accounts to track restricted grant funding separately from general donations.
1 Oct 2026
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.
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 #
// 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/ArticleTeaserBindingMapping 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.
// 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 ≈ ContentRefApplying 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 #
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.
// 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
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 #
| Scenario | Use |
|---|---|
| Multiple page types share SEO, category, or date fields | Contract |
| Query all pages of different types in one Graph request | Contract → interface query |
| A teaser block should always reflect its parent article's title/image | Binding + Mappings |
| Editors need to pick any item from a list of content | contentReference property |
| External product catalog should sync into CMS content types | Content Source API (see External Content demo) |
| One type's data should populate a different type's nested component | Binding on a component property |