Variation #2Continue reading...

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.

SDK · config · getClient · withAppContextOptimizelyComponent · OptimizelyComposition · OptimizelyGridSection15 blocks · display templates · display settings

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 HeroBlock

OptimizelyComposition

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

  1. Create src/components/blocks/MyBlock/index.tsx — export MyBlockType (contentType) and default component.
  2. Add MyBlockType to initContentTypeRegistry() in componentRegistry.ts.
  3. Add MyBlock to initReactComponentRegistry() resolver — use { default: MyBlock, tags: { Variant: MyBlockVariant } } if you have display template variants.
  4. Register display templates via initDisplayTemplateRegistry().
  5. 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.

BlockDisplay templates (tag key)
HeroBlockHeroCenteredTemplate (tag: Centered)
ProductHeroBlockProductHeroCompactTemplate (tag: Compact)
SectionHeadingBlockSectionHeadingCenteredTemplate (tag: Centered)
RichTextBlockTextBlockNarrowTemplate (tag: Narrow)
CallToActionBlockCallToActionOutlineTemplate, CallToActionSurfaceTemplate
ProductCardBlockProductCardFeaturedTemplate (tag: Featured)
FeatureItemBlockFeatureItemOutlinedTemplate, FeatureItemFlatTemplate
TestimonialBlockTestimonialCardTemplate (tag: Card)
StatsCounterBlock
ImageBlockImageBlockRoundedTemplate (tag: Rounded)
FaqContainerBlock
FaqItemBlock
FeaturedContentBlock
LogoGridBlock
FormContainerBlock
Source files2 files
src/components/blocks/HeroBlock/index.tsx
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>
  );
}
src/components/blocks/HeroBlock/fragment.ts
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
  }
`;