If you’ve worked with Optimizely CMS’s code-first approach, you’ve likely run into a familiar pattern: a container component with a content area that accepts a handful of specific components. You define those components in the allowedTypes array, and everything works — until the next sprint when a new card variant needs to be added. Then another. And another.
Before long, you’re updating allowedTypes in multiple containers every time a new component is introduced. It’s tedious, error-prone, and doesn’t scale.
What if there was a way to say “allow any component that qualifies as a card“ — without listing them one by one?
Turns out, there is. Optimizely’s code-first SDK supports contracts in allowedTypes. Instead of referencing individual content types, you reference a contract. Any component that extends that contract is automatically allowed. Add a new component tomorrow, and as long as it extends the contract, it’s instantly droppable into every container that references it — zero changes required.
The Problem with Explicit allowedTypes
In Optimizely CMS’s code-first content modeling, the allowedTypes property on content areas controls which content types editors can drop into a given container. The typical implementation looks like this:
cards: {
type: 'array',
items: {
type: 'content',
allowedTypes: [
CardVariantACT,
CardVariantBCT,
CardVariantCCT,
],
},
}
This works, but it introduces tight coupling between the container and every individual component it accepts. Each time a new card variant is introduced, every container that should accept it must be located and updated. In a large codebase with multiple containers referencing overlapping sets of components, this becomes a maintenance burden and a source of regressions.
The underlying issue is that allowedTypes is being used to enumerate concrete types when what we actually want to express is a constraint: “accept any component that behaves as a card.“
Contracts as Type Constraints
The @optimizely/cms-sdk provides a contract() factory function that creates a named contract definition. Components can extend one or more contracts via the extends property. This is primarily documented as a mechanism for shared property inheritance, but the SDK’s type system reveals a broader capability.
Implementation:
- A contract — an empty shared marker that defines the “card” family
- Two components — each extending the contract, with their own unique fields
- A container — with an
allowedTypesthat references the contract instead of individual components
Step 1: Define the Contract
The contract can be completely empty. It doesn’t need any properties — it simply acts as a grouping mechanism.
import { contract } from '@optimizely/cms-sdk';
export const POC_CommonCardContract = contract({
key: 'POC_CommonCardContract',
displayName: 'POC Common Card Contract',
});
No properties, no complexity. Just a key and a display name.
Step 2: Create Components That Extend the Contract
Each card component extends the contract and defines its own fields independently.
Card Component A — a card with a badge:
import { contentType } from '@optimizely/cms-sdk';
import { POC_CommonCardContract } from '../contract/POC_CommonCardContract';
export const POC_CardComponentACT = contentType({
key: 'POC_CardComponentA',
displayName: 'POC Card Component A',
baseType: '_component',
compositionBehaviors: ['sectionEnabled', 'elementEnabled'],
extends: [POC_CommonCardContract],
properties: {
title: { type: 'string', displayName: 'Title', isLocalized: true, group: 'Content', maxLength: 100 },
description: { type: 'richText', displayName: 'Description', isLocalized: true, group: 'Content' },
image: { type: 'contentReference', displayName: 'Image', group: 'Content', allowedTypes: ['_image'] },
badgeText: { type: 'string', displayName: 'Badge Text', group: 'Content', maxLength: 30 },
},
});
Card Component B — a card with a CTA link:
import { contentType } from '@optimizely/cms-sdk';
import { POC_CommonCardContract } from '../contract/POC_CommonCardContract';
export const POC_CardComponentBCT = contentType({
key: 'POC_CardComponentB',
displayName: 'POC Card Component B',
baseType: '_component',
compositionBehaviors: ['sectionEnabled', 'elementEnabled'],
extends: [POC_CommonCardContract],
properties: {
heading: { type: 'string', displayName: 'Heading', isLocalized: true, group: 'Content', maxLength: 150 },
body: { type: 'richText', displayName: 'Body', isLocalized: true, group: 'Content' },
thumbnail: { type: 'contentReference', displayName: 'Thumbnail', group: 'Content', allowedTypes: ['_image'] },
ctaUrl: { type: 'link', displayName: 'CTA Link', group: 'Content' },
},
});
Two completely different components, one shared contract.
Step 3: The Container — Where the Magic Happens
Here’s the key part. Instead of listing both components individually in allowedTypes, we reference the contract:
import { contentType } from '@optimizely/cms-sdk';
import { POC_CommonCardContract } from '../contract/POC_CommonCardContract';
export const POC_CardContainerCT = contentType({
key: 'POC_CardContainer',
displayName: 'POC Card Container',
baseType: '_component',
compositionBehaviors: ['sectionEnabled'],
properties: {
heading: { type: 'string', displayName: 'Heading', group: 'Content', maxLength: 100 },
cards: {
type: 'array',
displayName: 'Cards',
group: 'Content',
items: {
type: 'content',
allowedTypes: [POC_CommonCardContract],
},
},
},
});
Notice allowedTypes: [POC_CommonCardContract] — not a single component is listed. The CMS resolves this at runtime: any component that extends POC_CommonCardContract is automatically allowed.
4. Registration
Both the contract and the components must be registered. The contract file goes into the components array in optimizely.config.mjs:
import { buildConfig } from '@optimizely/cms-sdk';
export default buildConfig({
components: [
'./src/content-types/contract/POC_CommonCardContract.ts',
'./src/content-types/component/POC_CardComponentACT.ts',
'./src/content-types/component/POC_CardComponentBCT.ts',
'./src/content-types/component/POC_CardContainerCT.ts',
],
});
The contract and component must also be included in the runtime registry:
import { POC_CommonCardContract } from '@/src/content-types/contract/POC_CommonCardContract';
import { POC_CardComponentACT } from '@/src/content-types/component/POC_CardComponentACT';
import { POC_CardComponentBCT } from '@/src/content-types/component/POC_CardComponentBCT';
import { POC_CardContainerCT } from '@/src/content-types/component/POC_CardContainerCT';
initContentTypeRegistry([
POC_CommonCardContract,
POC_CardComponentACT,
POC_CardComponentBCT,
POC_CardContainerCT,
]);
After syncing to CMS, the Card Container’s cards content area will present both Card Component A and Card Component B as droppable options — without either being explicitly referenced in the container definition.

Why This Matters
The real power shows up on day two. When a new requirement comes in for Card Component C, D, or E, you simply create the component, add extends: [POC_CommonCardContract], and you’re done. The Card Container — and any other container referencing the contract — accepts the new component immediately. No config changes, no forgotten updates, no broken content areas.
This is the Open-Closed Principle applied to content modeling: your containers are open for extension but closed for modification.
Known Issue: Contract Expansion in Array Properties
While implementing this pattern, we encountered an issue in @optimizely/cms-sdk@2.2.0. When expandContracts: true is configured on the GraphClient, the contract expansion works correctly for single content type properties but does not work for array properties with content items.
The root cause is in the SDK’s internal handleArrayProperty function, which does not pass the expandContracts option through to the content property handler. As a result, the generated GraphQL query only includes the contract’s interface fragment and does not fetch the actual properties of the concrete component types.
CMS-side behavior is unaffected — both components are correctly allowed in the content area. The issue is limited to the frontend GraphQL query generation.
This has been accepted as a bug by the Optimizely team and is being tracked under CMS-54935. In the meantime, a workaround is to expand contracts at runtime in the component registry after initContentTypeRegistry using the SDK’s isContract utility to scan and resolve extending types automatically.
For a deep dive into exactly which property shapes break, why they break, and a generic runtime workaround, see the follow-up post: When expandContracts Doesn’t Expand: What We Found and How We Fixed It.
Final Thoughts
Contracts in Optimizely’s code-first SDK are an underused feature. Most developers use them for shared property inheritance, but their ability to act as type markers for allowedTypes unlocks a much cleaner, more scalable content architecture. If you find yourself constantly updating allowedTypes lists across your codebase, this pattern is worth adopting.
Happy Optimizing!!!