Editors often want the same component to look different depending on context. A banner might need a festive look during the holidays, or a hero section might need to switch between a tall layout and a full-screen one. Instead of building separate components for each case, Optimizely’s Content JS SDK gives you a cleaner way to handle this: display templates.
A display template lets you define multiple variants of the same content type. Each variant carries its own tag and its own settings, so editors can pick a look from a dropdown in the CMS without touching code or asking a developer to ship a new component.
In this post, I’ll walk through how displayTemplate() works using two variants of a single BannerBlock:
SplitBannerDT— a select setting that flips image position between left and rightMinimalBannerDT— a select setting with three alignment choices for text
Wiring It Together: Setting Up Display Templates
A display template goes through four steps before an editor can select it: define it in code, push it to Optimizely CMS, register it in the project, then build the component that renders it.
1. Defining Display Templates in Code
Display templates are defined using displayTemplate() from @optimizely/cms-sdk. Each template links back to a contentType via the key/contentType fields, and carries a unique tag that the component layer later uses to pick the right rendering.
Add both templates to src/content-types/component/BannerBlock.ts file.
import { contentType, displayTemplate } from '@optimizely/cms-sdk';
/**
* BannerBlock
*/
export const BannerBlockCT = contentType({
key: 'BannerBlock',
displayName: 'BannerBlock',
baseType: '_component',
compositionBehaviors: ['sectionEnabled', 'elementEnabled'],
properties: {
BannerTitle: { type: 'string', displayName: 'BannerTitle', group: 'Content', sortOrder: 0, format: 'shortString' },
BannerImage: { type: 'url', displayName: 'BannerImage', group: 'Content', sortOrder: 0, format: 'ImageUrl' },
},
});
/**
* Split Banner
*/
export const SplitBannerDT = displayTemplate({
key: 'SplitBanner',
contentType: 'BannerBlock',
displayName: 'Split Banner',
isDefault: false,
tag: 'SplitBannerTag',
settings: {
imagePosition: {
displayName: 'Image position',
editor: 'select',
sortOrder: 0,
choices: {
left: {
displayName: 'Left',
sortOrder: 1,
},
right: {
displayName: 'Right',
sortOrder: 2,
},
},
},
},
});
/**
* Minimal Banner
*/
export const MinimalBannerDT = displayTemplate({
key: 'MinimalBanner',
contentType: 'BannerBlock',
displayName: 'Minimal Banner',
isDefault: false,
tag: 'MinimalBannerTag',
settings: {
textAlign: {
displayName: 'Text alignment',
editor: 'select',
sortOrder: 0,
choices: {
left: {
displayName: 'Left',
sortOrder: 1,
},
center: {
displayName: 'Center',
sortOrder: 2,
},
right: {
displayName: 'Right',
sortOrder: 3,
},
},
},
},
});
A few things worth calling out here:
keyuniquely identifies the template;contentTypeties it toBannerBlock, so both templates only ever show up as options for banner content.isDefault: falseon both means neither template is auto-selected — editors have to explicitly pick one from the dropdown.settingsdrives the editor UI. Both templates use aselecteditor here, but the SDK also supports simpler editors likecheckboxfor boolean toggles.tagis the string your component code will match against later.
2: Pushing Display Templates to Optimizely CMS
Once the templates are defined in code, they need to be synced to your CMS instance so editors can actually see them as options. This is handled by @optimizely/cms-cli.
Make sure your optimizely.config.mjs includes the path to your content types.
import { buildConfig } from '@optimizely/cms-sdk';
export default buildConfig({
components: ['./src/content-types/**/*.ts'],
});
Then run the push command:
npx @optimizely/cms-cli config push
Once the push succeeds, log into the CMS and open a BannerBlock — the display template dropdown should now list Split Banner and Minimal Banner alongside any default template. You can check these options under the style section inside the component.

For more details on configuring BannerBlock component you can check my previous blog here
Step 3: Building Components for Each Variant
With the display templates pushed to Optimizely CMS, editors can now select Split Banner or Minimal Banner from the dropdown. But selecting a template doesn’t render anything by itself — you still need a React component for each variant that knows how to read its settings and render the right markup.
Create a component for SplitBannerDT:
import type { ContentProps } from '@optimizely/cms-sdk';
import { getPreviewUtils } from '@optimizely/cms-sdk/react/server';
import type { BannerBlockCT } from '@/src/content-types/component/BannerBlock';
type BannerBlockContent = ContentProps<typeof BannerBlockCT>;
type Props = {
content: BannerBlockContent;
displaySettings?: Record<string, string | boolean>;
};
export default function SplitBanner({ content, displaySettings }: Props) {
const { pa } = getPreviewUtils(content);
const imageUrl = content.BannerImage?.default ?? content.BannerImage?.hierarchical;
const imageOnRight = displaySettings?.imagePosition === 'right';
return (
<section
className={`flex w-full flex-col overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-800 md:flex-row ${
imageOnRight ? 'md:flex-row-reverse' : ''
}`}
>
{imageUrl && (
<div {...pa('BannerImage')} className="relative aspect-[3/2] w-full md:w-1/2">
<img
src={imageUrl}
alt={content.BannerTitle ?? ''}
className="h-full w-full object-cover"
/>
</div>
)}
<div className="flex w-full items-center px-6 py-8 md:w-1/2">
{content.BannerTitle && (
<h2
{...pa('BannerTitle')}
className="text-3xl font-bold text-zinc-900 dark:text-zinc-50"
>
{content.BannerTitle}
</h2>
)}
</div>
</section>
);
}
And one for MinimalBannerDT:
import type { ContentProps } from '@optimizely/cms-sdk';
import { getPreviewUtils } from '@optimizely/cms-sdk/react/server';
import type { BannerBlockCT } from '@/src/content-types/component/BannerBlock';
type BannerBlockContent = ContentProps<typeof BannerBlockCT>;
type Props = {
content: BannerBlockContent;
displaySettings?: Record<string, string | boolean>;
};
const ALIGN_CLASS: Record<string, string> = {
left: 'text-left items-start',
center: 'text-center items-center',
right: 'text-right items-end',
};
export default function MinimalBanner({ content, displaySettings }: Props) {
const { pa } = getPreviewUtils(content);
const alignClass =
ALIGN_CLASS[String(displaySettings?.textAlign ?? 'left')] ?? ALIGN_CLASS.left;
return (
<section
className={`flex w-full flex-col justify-center border-l-4 border-zinc-900 px-6 py-10 dark:border-zinc-100 ${alignClass}`}
>
{content.BannerTitle && (
<h2
{...pa('BannerTitle')}
className="text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"
>
{content.BannerTitle}
</h2>
)}
</section>
);
}
A few things worth noting:
- Both components share the same underlying
BannerBlockcontent type — only thesettingsfields they read (imagePositionvs.textAlign) differ. - Keeping each variant as its own component (rather than one component with a lot of conditionals) keeps the markup readable as you add more variants later.
4. Registering Display Templates in the Project
Building the component isn’t enough on its own — the SDK still needs to know which component maps to which tag. That mapping happens in the component registry, via initReactComponentRegistry.
import { initContentTypeRegistry, initDisplayTemplateRegistry } from '@optimizely/cms-sdk';
import { initReactComponentRegistry } from '@optimizely/cms-sdk/react/server';
import { BannerBlockCT, SplitBannerDT, MinimalBannerDT } from '@/src/content-types/component/BannerBlock';
import BannerBlock from '@/src/components/component/BannerBlock';
import SplitBanner from '@/src/components/component/SplitBanner';
import MinimalBanner from '@/src/components/component/MinimalBanner';
// Register the BannerBlock content type so the CMS/SDK knows its schema
initContentTypeRegistry([BannerBlockCT]);
// Register the display templates (variants) available for BannerBlock
initDisplayTemplateRegistry([
SplitBannerDT,
MinimalBannerDT,
]);
// Map each content type / display template tag to the React component
// that should render it
initReactComponentRegistry({
resolver: {
BannerBlock: BannerBlock,
'BannerBlock:SplitBannerTag': SplitBanner,
'BannerBlock:MinimalBannerTag': MinimalBanner
},
});
A few things to keep in mind here:
'ContentType:Tag'is the resolution key — the SDK looks up components usingcontentType:tagas a compound key.'BannerBlock:SplitBannerTag'must exactly match thetagvalue set onSplitBannerDTin Step 1, or the SDK won’t find a match.- The bare
BannerBlockentry is the fallback — when content doesn’t have a display template selected (orisDefault: truetemplate applies), the resolver falls back to the plainBannerBlockcomponent. - Adding a new variant is additive, not disruptive — a third banner variant just needs one more
displayTemplate()export, one more component, and one more line inresolver. Nothing else in this file changes.
With everything wired up, editors pick a display template from the CMS dropdown, and the SDK renders the matching component automatically — no per-page wiring, no extra code.
A Note on Display Settings in Experience Pages
If you’re using Content JS SDK version < 2.1, you’ll hit this issue: by default, displaySettings isn’t passed down to the inner component when a block renders inside an Experience page. To get the selected variant’s settings to reach your component, you’ll need to modify OptimizelyComposition as shown below.
import {
OptimizelyComposition,
type ComponentContainerProps,
} from '@optimizely/cms-sdk/react/server';
// OptimizelyComposition passes each component node's displaySettings to this
// wrapper but not to the inner OptimizelyComponent, so forward them explicitly.
function ComponentWrapper({ children, displaySettings }: ComponentContainerProps) {
if (React.isValidElement(children)) {
return React.cloneElement(
children as React.ReactElement<{ displaySettings?: Record<string, string | boolean> }>,
{ displaySettings },
);
}
return <>{children}</>;
}
// Pass the custom wrapper to OptimizelyComposition so every node
// in the composition gets displaySettings forwarded through it
<OptimizelyComposition
nodes={content.composition.nodes}
ComponentWrapper={ComponentWrapper}
/>
Recommended: Upgrade to Content JS SDK 2.1 to avoid this issue altogether.
Final Thoughts
Display templates give editors real flexibility — one BannerBlock can become a split layout, a minimal centered banner, or any variant you define, all without new code per page. The workflow itself is straightforward: define the template, push it to Optimizely CMS, build the component, and register it.
If you’re building component variants on your own project, start small — one content type, two templates — before scaling up. It’s a lot easier to debug a tag mismatch or a missing registry entry with two variants than with ten.
Happy Optimizing!!!