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.
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.
5 Savings Tips for 2025
From emergency funds to high-interest accounts, five habits that will make your money work harder this year.
20 Jul 2026
InstanceMetadataBusiness Banking Basics
How to choose the right current account, manage cash flow, and integrate banking with your accounting tools.
20 Jul 2026
InstanceMetadataGuide to ISAs
Everything you need to know about Individual Savings Accounts — from annual limits to tax-free growth.
20 Jul 2026
InstanceMetadataFrom renters to homeowners: a Manchester family's 18-month plan
Liam and Saoirse used Mosey's fixed-rate savings account and budgeting tools to reach their first-home deposit in less than half the time they expected.
20 Jul 2026
InstanceMetadataHow a Manchester pâtisserie tripled revenue in 18 months
A six-year-old high-street bakery moved its banking, payments, and lending to Mosey and used the unlocked cash flow to hire seven new staff.
20 Jul 2026
InstanceMetadataBusiness banking essentials for founders in year one
Five banking decisions that quietly determine whether your first year is calm or chaotic. Most founders only realise they got them wrong at month nine.
20 Jul 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 |