Developer Demo
Visual Builder
How the Optimizely CMS SDK turns Visual Builder page compositions into rendered React — using the SDK's built-in rendering pipeline instead of hand-written GraphQL queries or manual tree-walking.
Composition Model #
Visual Builder pages are a tree. The SDK flattens and dispatches that tree through three components — one per level.
Experience (DynamicExperience)
└── composition.nodes
└── Section node → OptimizelyComposition dispatches to BlankSection
└── content.nodes
└── Row → OptimizelyGridSection dispatches to Row component
└── Column → dispatches to Column component
└── HeroBlock → OptimizelyComponent dispatches to HeroBlockOptimizelyComposition
Iterates composition.nodes. Dispatches section nodes to their registered component. Wraps leaf blocks with ComponentWrapper.
OptimizelyGridSection
Iterates content.nodes (rows/columns). Calls your custom row and column wrappers so you control layout with Tailwind.
OptimizelyComponent
Reads content.__typename (and __tag for display template variants), looks up the resolver, renders the matching React component.
Page Route #
config() sets the Graph credentials once at module init. Every page route then calls getClient() — no env vars threaded through props. The SDK auto-generates the full GraphQL query from all registered content types, so one getContentByPath() call fetches the page and every possible block type in a single round-trip. The withAppContext HOC initialises request-scoped context storage required for preview utilities.
// src/app/[[...slug]]/page.tsx
import { getClient } from "@optimizely/cms-sdk";
import { OptimizelyComponent, withAppContext } from "@optimizely/cms-sdk/react/server";
import { initComponentRegistry } from "@/lib/optimizely/componentRegistry";
initComponentRegistry(); // registers types + calls config()
async function CmsPage({ params }) {
const { slug } = await params;
const client = getClient(); // no env vars needed here — config() set them once
// SDK auto-generates the full GraphQL query from all registered content types.
// One call fetches the page + every possible block type in a single round-trip.
const [page] = await client.getContentByPath(`/en/${slug.join("/")}/`);
return <OptimizelyComponent content={page} />;
// OptimizelyComponent reads page.__typename → dispatches to DynamicExperience
// or TraditionalPage via the resolver — no manual type switching needed.
}
export default withAppContext(CmsPage);Component Registry #
initComponentRegistry() is called once (guarded by an initialized flag) and registers all content types, display templates, and React components. Display template variants use the tags pattern so the SDK routes by displayTemplateKey automatically — no manual if/switch on the template key in components.
// src/lib/optimizely/componentRegistry.ts
import { config, initContentTypeRegistry, initDisplayTemplateRegistry } from "@optimizely/cms-sdk";
import { initReactComponentRegistry } from "@optimizely/cms-sdk/react/server";
import HeroBlock, { HeroBlockType, HeroCenteredTemplate } from "@/components/blocks/HeroBlock";
import DynamicExperience from "@/components/experience/DynamicExperience";
import BlankSection from "@/components/experience/BlankSection";
// Configure Graph client once — all getClient() calls in page routes use this.
config({ apiKey: process.env.OPTIMIZELY_GRAPH_SINGLE_KEY ?? "" });
export function initComponentRegistry() {
initContentTypeRegistry([HeroBlockType, /* … */]);
initDisplayTemplateRegistry([HeroCenteredTemplate, /* … */]);
initReactComponentRegistry({
resolver: {
// Experience / section types
DynamicExperience,
BlankSection,
// Blocks — tags map displayTemplateKey → component variant
HeroBlock: {
default: HeroBlock,
tags: { Centered: HeroCenteredTemplate }, // HeroCenteredTemplate.tag = "Centered"
},
},
});
}Experience & Section Components #
The SDK provides OptimizelyComposition and OptimizelyGridSection to walk the composition tree. You only need to supply the layout components — the SDK handles all JSON traversal.
DynamicExperience — top-level composition entry point
// src/components/experience/DynamicExperience.tsx
import { OptimizelyComposition, getPreviewUtils, type ComponentContainerProps }
from "@optimizely/cms-sdk/react/server";
// Wraps each component node with preview attributes so editors can click-to-edit.
function ComponentWrapper({ children, node }: ComponentContainerProps) {
const { pa } = getPreviewUtils(node);
return <div {...pa(node)}>{children}</div>;
}
export default function DynamicExperience({ content }: { content: any }) {
// content.composition.nodes = top-level section + standalone element nodes.
// OptimizelyComposition walks the tree:
// - Component nodes → ComponentWrapper → OptimizelyComponent (dispatches to block)
// - Section nodes → OptimizelyComponent (dispatches to BlankSection)
return (
<OptimizelyComposition
nodes={content?.composition?.nodes ?? []}
ComponentWrapper={ComponentWrapper}
/>
);
}BlankSection — row/column grid rendering
// src/components/experience/BlankSection.tsx
import { OptimizelyGridSection, getPreviewUtils, type StructureContainerProps }
from "@optimizely/cms-sdk/react/server";
function Row({ children, node, displaySettings }: StructureContainerProps) {
const { pa } = getPreviewUtils(node);
const count = (node as any).nodes?.length ?? 1;
const grid = count === 2 ? "md:grid-cols-2"
: count === 3 ? "md:grid-cols-3"
: count >= 4 ? "md:grid-cols-4" : "";
const gap = displaySettings?.gap === "compact" ? "gap-4"
: displaySettings?.gap === "spacious" ? "gap-16" : "gap-8";
return (
<div className={[count > 1 ? `grid grid-cols-1 ${grid}` : "", gap].join(" ")} {...pa(node)}>
{children}
</div>
);
}
function Column({ children, node, displaySettings }: StructureContainerProps) {
const { pa } = getPreviewUtils(node);
const bg = displaySettings?.background === "surface" ? "bg-surface" : "";
const padding = displaySettings?.padding === "compact" ? "p-4" : "";
const rounded = displaySettings?.rounded ? "rounded-2xl" : "";
return (
<div className={[bg, padding, rounded].join(" ")} {...pa(node)}>{children}</div>
);
}
export default function BlankSection({ content }: { content: any }) {
const { pa } = getPreviewUtils(content);
// content.nodes = row/column nodes inside the section.
// OptimizelyGridSection walks rows → columns → dispatches leaf blocks.
return (
<section {...pa(content)}>
<OptimizelyGridSection nodes={content?.nodes ?? []} row={Row} column={Column} />
</section>
);
}Preview Route #
getPreviewContent() reads the preview_token, key, and ver query params, fetches the draft content, and stores them in the withAppContext context — which getPreviewUtils reads to know whether to emit data-epi-* attributes. The rendered output goes through the exact same OptimizelyComponent path as the published page — no separate preview renderer.
// src/app/preview/page.tsx
export const dynamic = "force-dynamic";
import { getClient, type PreviewParams } from "@optimizely/cms-sdk";
import { OptimizelyComponent, withAppContext } from "@optimizely/cms-sdk/react/server";
import { PreviewComponent } from "@optimizely/cms-sdk/react/client";
import Script from "next/script";
async function PreviewPage({ searchParams }) {
const params = await searchParams;
const client = getClient();
// getPreviewContent reads preview_token, key, ver, ctx from query params,
// fetches the draft version, and populates the withAppContext context store.
const content = await client.getPreviewContent(params as PreviewParams);
return (
<>
{/* Establishes the postMessage channel between the CMS iframe and this page. */}
<Script src={`${process.env.NEXT_PUBLIC_OPTIMIZELY_CMS_URL}/util/javascript/communicationinjector.js`} />
{/* SDK client component that receives live content-change events from the CMS. */}
<PreviewComponent />
{/* Same dispatch path as the published page — no separate preview renderer needed. */}
<OptimizelyComponent content={content} />
</>
);
}
export default withAppContext(PreviewPage);Building a Block #
Each block colocates its contentType() definition, optional displayTemplate() definitions, and the React component. The component receives typed content and displaySettings props; the SDK dispatches the right variant via the tags registry entry.
Content type + display template
import { contentType, displayTemplate } from "@optimizely/cms-sdk";
export const HeroBlockType = contentType({
key: "HeroBlock",
displayName: "Hero Block",
baseType: "_component",
compositionBehaviors: ["sectionEnabled", "elementEnabled"],
properties: {
headline: { type: "string", indexingType: "searchable" },
subheadline: { type: "string", indexingType: "searchable" },
backgroundImage: { type: "contentReference", allowedTypes: ["_image"] },
ctaText: { type: "string" },
ctaLink: { type: "string" },
},
});
export const HeroCenteredTemplate = displayTemplate({
key: "HeroCenteredTemplate",
displayName: "Centered Hero",
contentType: "HeroBlock",
tag: "Centered", // links to the "Centered" key in the resolver tags object
settings: {
height: {
editor: "select",
choices: { default: { displayName: "Default" }, tall: { displayName: "Full Viewport" } },
},
overlay: { editor: "checkbox", displayName: "Dark Overlay on Image" },
},
});React component (typed props + display settings)
type HeroBlockProps = {
content: ContentProps<typeof HeroBlockType>;
displaySettings?: ContentProps<typeof HeroCenteredTemplate>;
};
export default function HeroBlock({ content, displaySettings }: HeroBlockProps) {
const { pa, src } = getPreviewUtils(content); // src appends preview tokens to DAM image URLs
const isTall = displaySettings?.height === "tall";
const showOverlay = displaySettings?.overlay === true;
return (
<section className={isTall ? "min-h-screen" : "min-h-[640px]"}>
{content.backgroundImage && (
<Image
src={src(content.backgroundImage)}
className={showOverlay ? "opacity-20" : "opacity-30"}
fill
/>
)}
<h1 {...pa("headline")}>{content.headline}</h1>
<p {...pa("subheadline")}>{content.subheadline}</p>
<a href={content.ctaLink}>{content.ctaText}</a>
</section>
);
}Checklist — adding a new block
- Create
src/components/blocks/MyBlock/index.tsx— exportMyBlockType(contentType) and default component. - Add
MyBlockTypetoinitContentTypeRegistry()incomponentRegistry.ts. - Add
MyBlocktoinitReactComponentRegistry()resolver — use{ default: MyBlock, tags: { Variant: MyBlockVariant } }if you have display template variants. - Register display templates via
initDisplayTemplateRegistry(). - Push updated content types to CMS:
npm run opti:push
Registered Blocks #
All blocks registered in componentRegistry.ts. Tags are the key the SDK uses to dispatch to a variant component when an editor selects that display template in Visual Builder.
| Block | Display templates (tag key) |
|---|---|
| HeroBlock | HeroCenteredTemplate (tag: Centered) |
| ProductHeroBlock | ProductHeroCompactTemplate (tag: Compact) |
| SectionHeadingBlock | SectionHeadingCenteredTemplate (tag: Centered) |
| RichTextBlock | TextBlockNarrowTemplate (tag: Narrow) |
| CallToActionBlock | CallToActionOutlineTemplate, CallToActionSurfaceTemplate |
| ProductCardBlock | ProductCardFeaturedTemplate (tag: Featured) |
| FeatureItemBlock | FeatureItemOutlinedTemplate, FeatureItemFlatTemplate |
| TestimonialBlock | TestimonialCardTemplate (tag: Card) |
| StatsCounterBlock | — |
| ImageBlock | ImageBlockRoundedTemplate (tag: Rounded) |
| FaqContainerBlock | — |
| FaqItemBlock | — |
| FeaturedContentBlock | — |
| LogoGridBlock | — |
| FormContainerBlock | — |
Source files2 files
import Image from "next/image";
import { contentType, displayTemplate } from "@optimizely/cms-sdk";
import { getPreviewUtils } from "@optimizely/cms-sdk/react/server";
export const HeroBlockType = contentType({
key: "HeroBlock",
displayName: "Hero Block",
baseType: "_component",
compositionBehaviors: ["sectionEnabled", "elementEnabled"],
properties: {
headline: { type: "string", displayName: "Headline", indexingType: "searchable" },
subheadline: { type: "string", displayName: "Subheadline", indexingType: "searchable" },
backgroundImage: { type: "contentReference", displayName: "Background Image", allowedTypes: ["_image"], indexingType: "disabled" },
ctaText: { type: "string", displayName: "CTA Text" },
ctaLink: { type: "string", displayName: "CTA Link" },
},
});
export const HeroCenteredTemplate = displayTemplate({
key: "HeroCenteredTemplate",
isDefault: false,
displayName: "Centered Hero",
contentType: "HeroBlock",
tag: "Centered",
settings: {
height: {
editor: "select",
displayName: "Height",
sortOrder: 0,
choices: {
default: { displayName: "Default", sortOrder: 0 },
tall: { displayName: "Full Viewport", sortOrder: 1 },
},
},
overlay: {
editor: "checkbox",
displayName: "Dark Overlay on Image",
sortOrder: 1,
choices: {},
},
},
});
interface HeroBlockData {
headline?: string | null;
subheadline?: string | null;
heading?: string | null;
summary?: string | null;
backgroundImage?: {
_metadata?: { url?: { default?: string | null } | null } | null;
} | null;
background?: {
_metadata?: { url?: { default?: string | null } | null } | null;
} | null;
ctaText?: string | null;
ctaLink?: string | null;
__context?: { edit?: boolean } | null;
}
type HeroBlockProps = HeroBlockData & {
content?: HeroBlockData;
displaySettings?: Record<string, string | boolean>;
};
export default function HeroBlock(props: HeroBlockProps) {
const data = props.content ?? props;
const ds = props.displaySettings;
const { pa } = getPreviewUtils(data as any);
const title = data.headline ?? data.heading;
const subtitle = data.subheadline ?? data.summary;
const bgUrl =
data.backgroundImage?._metadata?.url?.default ??
data.background?._metadata?.url?.default;
const isCentered = ds?.alignment === "center";
const isTall = ds?.height === "tall";
const showOverlay = ds?.overlay === true;
return (
<section
className={`bg-gradient-brand relative w-full flex items-center overflow-hidden ${isTall ? "min-h-screen" : "min-h-[640px]"}`}
>
{bgUrl && (
<Image
src={bgUrl}
alt={data.headline ?? ""}
fill
className={`object-cover ${showOverlay ? "opacity-20" : "opacity-30"}`}
priority
/>
)}
<div
className={`relative z-10 max-w-7xl mx-auto px-8 py-32 w-full ${isCentered ? "text-center" : ""}`}
>
<div className={isCentered ? "max-w-3xl mx-auto" : "max-w-3xl"}>
{title && (
<h1
{...pa("headline")}
className="font-display text-5xl md:text-6xl lg:text-[3.5rem] font-extrabold leading-tight mb-8 text-on-brand"
>
{title}
</h1>
)}
{subtitle && (
<p
{...pa("subheadline")}
className="text-xl md:text-2xl mb-12 max-w-2xl leading-relaxed text-on-brand-subtle"
>
{subtitle}
</p>
)}
{(data.ctaLink || data.__context?.edit) && (
<div>
<a
href={data.__context?.edit ? undefined : (data.ctaLink ?? undefined)}
className="hover-lift font-display inline-block px-8 py-4 rounded-lg font-semibold text-lg bg-surface-lowest text-brand"
>
<span {...pa("ctaText")}>{data.ctaText ?? "Learn More"}</span>
</a>
{data.__context?.edit && (
<p
{...pa("ctaLink")}
className="mt-2 text-xs font-mono text-on-brand-subtle/70 cursor-pointer hover:text-on-brand-subtle transition-colors"
>
{data.ctaLink || "Click to set CTA link…"}
</p>
)}
</div>
)}
</div>
</div>
</section>
);
}
export const HERO_BLOCK_FRAGMENT = /* GraphQL */ `
fragment HeroBlockData on HeroBlock {
__typename
_metadata {
key
version
}
headline
subheadline
backgroundImage {
_metadata {
url {
default
}
}
}
ctaText
ctaLink
}
`;
export const HERO_FRAGMENT = /* GraphQL */ `
fragment HeroData on Hero {
__typename
_metadata {
key
version
}
heading
summary
theme
}
`;