Developer Demo

Content Modelling

How to structure content in a headless CMS so editors can work efficiently, developers can query predictably, and the design can evolve without breaking everything.

ComposableReusableType-safeGraph-ready

The Three-Tier Model #

Every piece of content in Visual Builder lives at one of three levels. Understanding this hierarchy determines what compositionBehaviors to assign and how editors build pages - before writing a single line of component code. SDK docs ↗

Experience  (DynamicExperience / LandingPage)   ← the page - owns the URL and SEO metadata
└── Section  (BlankSection / FaqContainerBlock)  ← layout container - groups elements into rows/columns
    └── Element  (HeroBlock / StatsCounterBlock) ← leaf block - pure content, no children

Experience

The page itself. Sets the URL, locale, SEO metadata, and overall layout strategy. Registered with baseType: "_experience".

Examples: DynamicExperience LandingPage

Section

A layout container inside the page. Groups elements into rows and columns. Must have sectionEnabled in compositionBehaviors. Can optionally hold a type: "array" content area.

Examples: FaqContainerBlock LogoGridBlock

Element

A leaf content block. Has no children. Placed inside sections by editors in Visual Builder. Must have elementEnabled in compositionBehaviors.

Examples: StatsCounterBlock FeatureItemBlock

Custom section with typed children

Experience - DynamicExperience

Section - FaqContainerBlock [sectionEnabled]

Element

FaqItemBlock

Element

FaqItemBlock

Element

FaqItemBlock

FaqContainerBlock has sectionEnabled and a type: "array" property - so editors add FaqItemBlock children directly on the section in Visual Builder. The SDK's built-in BlankSection works the same way at the layout level but accepts any elementEnabled block rather than a typed list.

Page Types - DynamicExperience vs TraditionalPage #

Optimizely SaaS CMS supports two base types for pages. Which one to use determines whether a page's layout is owned by the editor in Visual Builder or by the developer in React code. SDK docs ↗

_experiencebase type

DynamicExperience - editor-owned layout

  • The editor assembles the page in Visual Builder by placing blocks into sections and columns.
  • The React component (DynamicExperience) renders the composition tree using OptimizelyComposition - no layout logic lives in the component.
  • Best for marketing pages, landing pages, homepages - anything where an editor needs layout control.
  • Graph returns the full composition tree in one query - no extra fetches needed for composition nodes.

Examples in this repo

LandingPageBusinessBankingPageInvestmentsPage
_pagebase type

TraditionalPage - developer-owned layout

  • The React component defines the layout. The editor only fills in content fields (headline, body, heroImage, etc.).
  • Properties are defined with contentType() just like any block - Graph returns them as typed fields.
  • Best for structured content with a consistent layout: article pages, team profiles, case studies.
  • type: "content" single-reference fields return only base metadata from Graph - resolve them in the page component using getClient().getContent().

Examples in this repo

ArticlePageCaseStudyPageTeamMemberPage

Same dispatch path, different content

Both page types go through the same OptimizelyComponent resolver. The catch-all route calls getContentByPath(), which returns either a DynamicExperience or a TraditionalPage (or any other registered type). OptimizelyComponent reads __typename and dispatches to the matching React component - no if/switch on the type in the route. The key difference is what that component does with its content prop: a DynamicExperience renders content.composition.nodes with the SDK tree walker, while a TraditionalPage renders its own JSX layout using content.heading, content.body, etc.

DimensionDynamicExperience (_experience)TraditionalPage (_page)
Layout ownerEditor in Visual BuilderDeveloper in React
Graph payloadcomposition.nodes tree (full block data)Typed content fields (heading, body, etc.)
Single referencesN/A - blocks are inline in compositionBase metadata only - resolve in page component
Ideal forMarketing pages, campaign pages, homepagesArticles, profiles, structured documents
Editor autonomyHigh - editor controls block order and layoutLow - layout is fixed in code

elementEnabled vs sectionEnabled #

compositionBehaviors is the single most important property on a content type. It controls where editors can place a block in Visual Builder and whether it can contain other blocks. SDK docs ↗

["elementEnabled"]

Leaf node only. Cannot have a type: "array" content area property - the CMS will silently ignore it. Placed inside sections by editors.

["sectionEnabled"]

Container only. Can have type: "array" content areas. Cannot be placed inside another section. The SDK dispatches child blocks via OptimizelyGridSection.

["sectionEnabled", "elementEnabled"]

Flexible - editors can place it at either level. Use when a block works both standalone (e.g. a testimonial section) and inside a grid (e.g. a testimonial card within a 3-col row).

Rule of thumb: if the block has a type: "array" property → sectionEnabled. Pure content, no children → elementEnabled. Unsure → both.

elementEnabled - leaf block
// src/components/blocks/StatsCounterBlock/index.tsx
export const StatsCounterBlockType = contentType({
  key: "StatsCounterBlock",
  baseType: "_component",
  compositionBehaviors: ["elementEnabled"], // leaf - no children
  properties: {
    value:  { type: "string" },
    suffix: { type: "string" },
    label:  { type: "string" },
  },
});
sectionEnabled - container block
// src/components/blocks/FaqContainerBlock/index.tsx
export const FaqContainerBlockType = contentType({
  key: "FaqContainerBlock",
  baseType: "_component",
  compositionBehaviors: ["sectionEnabled"], // container - can hold children
  properties: {
    heading:  { type: "string" },
    faqItems: {
      type: "array",                          // content area - editors add items here
      items: { type: "content", allowedTypes: [FaqItemBlockType] },
    },
  },
});

Name for Purpose, Not Appearance #

Content type names should describe what the content is, not how it looks today. Visual names break the moment the design changes - and they mislead editors about what belongs inside a block. Display templates handle the how it looks side.

Do - semantic names

Name after the content's purpose or real-world concept.

  • TestimonialBlock - a customer quote with attribution
  • PricingTierBlock - a plan with price + feature list
  • SectionHeadingBlock - a heading + optional subheading
  • HeroBlock - the top-of-page primary message

Avoid - visual/presentation names

Avoid names that describe the CSS or layout - they rot fast.

  • BlueCardBlock - what if the colour changes?
  • BigBoldHeading - size is a display template setting
  • ThreeColumnGrid - column count is a layout concern
  • BigHeroWithOverlay - the overlay is a display setting
semantic naming
// Good - describes what the content IS
export const TestimonialBlockType = contentType({ key: "TestimonialBlock", … });
export const PricingTierBlockType  = contentType({ key: "PricingTierBlock",  … });
export const SectionHeadingBlockType = contentType({ key: "SectionHeadingBlock", … });
presentation naming - avoid
// Avoid - describes how it looks today (breaks after a redesign)
export const BlueCardBlockType     = contentType({ key: "BlueCardBlock",     … });
export const BigBoldHeadingType    = contentType({ key: "BigBoldHeading",    … });
export const ThreeColumnGridType   = contentType({ key: "ThreeColumnGrid",   … });

Display Template vs New Content Type #

The most common modelling decision: should a visual variation be a new content type or a display template on an existing one? The answer hinges on whether the fields differ. SDK docs ↗

Do - use a display template when

  • The fields are identical - only the visual style differs
  • An editor needs to pick a style without changing the content
  • Examples: same TestimonialBlock shown as a white card or dark blue card - same quote, same author, different background
  • Same SectionHeadingBlock shown left-aligned or centred

Do - create a new content type when

  • The content has different fields - a Testimonial has quote + author; a Pricing Tier has price + features list
  • Editors need to search for or reuse this content independently across pages
  • The content makes semantic sense as its own thing, not just a styled version of another
one content type, two display templates
// src/components/blocks/TestimonialBlock/index.tsx
// One content type - identical fields - two visual presentations.
export const TestimonialCardTemplate = displayTemplate({
  key: "TestimonialCardTemplate",
  displayName: "Quote in a card (boxed)",
  contentType: "TestimonialBlock",
  tag: "Card",              // links to resolver tags.Card
  settings: {
    theme: {
      editor: "select",
      choices: {
        default: { displayName: "White" },
        brand:   { displayName: "Dark blue (brand)" },
      },
    },
  },
});

export const TestimonialMinimalTemplate = displayTemplate({
  key: "TestimonialMinimalTemplate",
  displayName: "Inline quote, no background",
  contentType: "TestimonialBlock",
  tag: "Minimal",           // links to resolver tags.Minimal
  settings: { … },
});

// Registry maps both tags to the SAME component - it reads displayTemplateKey
// to switch rendering logic internally.
TestimonialBlock: {
  default: TestimonialBlock,
  tags: { Card: TestimonialBlock, Minimal: TestimonialBlock },
}

Content Reuse: Inline vs Referenced #

Blocks can be composed inline - created inside a page's Visual Builder session - or referenced - existing as independent CMS items linked from multiple pages. The choice affects how Graph fetches the data and how editors manage it. SDK docs ↗

Inline composition - type: "array"

  • Block is created inside the page - editing it affects only this page
  • Graph inline-expands type: "array" content areas automatically - no extra fetch needed
  • Best for page-specific content: hero text, feature lists, stats grids
  • Examples: FeatureItemBlock inside a business banking page, StatsCounterBlock in a grid

Referenced content - type: "contentReference"

  • Block exists as its own CMS item - editing it once updates everywhere it's used
  • Best for shared content: author bios, legal disclaimers, global FAQs
  • Graph returns only base metadata for single references - resolve full field data in the parent page component using getClient().getContent()
  • Examples: AuthorBlock linked from 10 articles, FaqContainerBlock on the FAQ page

Gotcha

type: "content" single references return only base metadata from Graph - regardless of whether the field is set. Graph only inline-expands type: "array" content areas. For referenced blocks that need their own field data, resolve them in the parent page component using getClient().getContent({ key }) before passing the resolved block down. Never add self-fetch logic inside the block itself.
inline - array content area (Graph auto-expands)
// Inline composition - content lives inside the page composition.
// Graph inline-expands type:"array" automatically. No extra fetch needed.
export const ProductHeroBlockType = contentType({
  properties: {
    title:    { type: "string" },
    features: {
      type: "array",
      items: { type: "content", allowedTypes: [FeatureItemBlockType] },
    },
  },
});
referenced - parent resolves via getClient().getContent()
// Referenced content - block exists independently, linked from many pages.
// Graph returns only base metadata (_Content) for type:"content" single references.
// Resolve the full item in the PARENT PAGE before passing it down.

// src/components/pages/TraditionalPage.tsx
export default async function TraditionalPage({ content }) {
  let featuredBlock = content.featuredBlock ?? null;

  // featuredBlock came back as _Content (base metadata only) - fetch the full item
  if (featuredBlock?.__typename === "_Content" && featuredBlock?._metadata?.key) {
    featuredBlock = await getClient()
      .getContent({ key: featuredBlock._metadata.key }, { next: { revalidate: 60 } })
      .catch(() => null);
  }

  return (
    // featuredBlock is now fully resolved - OptimizelyComponent can dispatch it
    <div>
      {featuredBlock && featuredBlock.__typename !== "_Content" && (
        <OptimizelyComponent content={featuredBlock} />
      )}
    </div>
  );
}

ContentArea - type: "array" (inline composition)

Page - Business Banking

Q: What are your business rates?
Q: How do I open an account?
Q: Can I add team members?

faqItems[ ] - lives inside this page composition

Items are created inside this page. Editing one affects only this page. Delete the page and the items are gone.

ContentReference - type: "contentReference" (shared item)

Article: Q3 Report
Article: Product Launch
Article: Year in Review

AuthorBlock - shared CMS item

Jane Smith

Senior Writer

The AuthorBlock exists independently. Editing it once updates every article that references it. Graph returns only its base metadata - the parent page resolves the full item via getClient().getContent() before rendering.

Choosing the Right Property Type #

Each property type maps to a different editor experience in the CMS and a different shape in the Graph response. Choosing correctly affects both the editing UX and how you render the field in React.

typeUse whenGraph returnsExamples
stringShort text, no formatting neededPlain stringtitle, ctaText, badge, value
richTextLong-form - editors need bold, links, headings{ json: {...} } - render with <RichText>bio, body, description
urlLinks and external URLs{ default: "https://…" }ctaLink, linkedinUrl
contentReferenceSingle image or content itemBase metadata only (_metadata.url)authorImage, backgroundImage
arrayOrdered list of blocks (content area)Full inline-expanded objectsfaqItems, logos, navItems

Indexing and localization

See the Graph Indexing and Localization section below for a full guide on indexingType values and isLocalized.

string
// string - short text, no formatting
headline:  { type: "string", displayName: "Headline", indexingType: "searchable", isLocalized: true },
badge:     { type: "string", displayName: "Badge Label",   isLocalized: true },
ctaText:   { type: "string", displayName: "Button Label",  isLocalized: true },
richText
// richText - long-form, editor gets a formatting toolbar
// Graph returns { json: {...} } - render with <RichText content={bio.json} />
bio:  { type: "richText", displayName: "Author Bio",    indexingType: "searchable", isLocalized: true },
body: { type: "richText", displayName: "Article Body",  indexingType: "searchable", isLocalized: true },
url
// url - Graph returns { default: "https://…" }
// Unwrap with: const href = value?.default ?? value
ctaLink:     { type: "url", displayName: "Button URL" },
linkedinUrl: { type: "url", displayName: "LinkedIn Profile" },
contentReference
// contentReference - single image or content item
// Graph returns only base metadata (_metadata.url, displayName, key).
// If you need full field data → resolve in the parent page component.
authorImage:     { type: "contentReference", allowedTypes: ["_image"],   indexingType: "disabled" },
backgroundImage: { type: "contentReference", allowedTypes: ["_image"],   indexingType: "disabled" },
array (content area)
// array - ordered list, inline-expanded by Graph automatically
// Use this for content areas editors populate in Visual Builder.
faqItems: {
  type: "array",
  items: { type: "content", allowedTypes: [FaqItemBlockType] },
},
logos: {
  type: "array",
  items: { type: "content", allowedTypes: ["_image"] },
},

Fetching Referenced Content #

For most blocks you do not write a GraphQL query at all. client.getContentByPath() in the catch-all page route fetches the full composition - every section, every inline element - in one request automatically.

The exception is referenced content. Graph does not inline-expand single type: "contentReference" properties - the component receives only base metadata (the item's key). To get full field data, call getClient().getContent({ key }) directly inside the component. No GraphQL query, no fragment file needed. SDK docs ↗

1

SDK fetches the page automatically

client.getContentByPath() retrieves the full composition. Inline blocks receive all their fields - no extra work required.

2

Referenced content returns keys only

Single contentReference properties are not inline-expanded. The component receives base metadata - key, URL - not the full fields.

3

Call getContent() with the key

getClient().getContent({ key }) fetches the full item. No manual GraphQL query. Works for single references and reference arrays alike.

single reference - ArticlePage fetching its author
// src/components/pages/ArticlePage.tsx
// The page receives an author contentReference - Graph returns only its key.
// getClient().getContent() fetches the full item without writing a query.
import { getClient } from "@optimizely/cms-sdk";

const author = await getClient().getContent(
  { key: content.author._metadata.key },
  { next: { revalidate: 300 } }
);
reference array - TimelineBlock fetching its milestones
// src/components/blocks/TimelineBlock/index.tsx
// milestones is a contentReference array - each item arrives as a key only.
// Fetch all in parallel; order is preserved by Promise.all.
import { getClient } from "@optimizely/cms-sdk";

const milestones = await Promise.all(
  keys.map((key) =>
    getClient().getContent({ key }, { next: { revalidate: 300 } })
  )
);

Which blocks use this in this demo

TimelineBlock, TeamGridBlock, ArticlePage, and CaseStudyPage all use getClient().getContent() to fetch their referenced content. Blocks whose content arrives fully inline-expanded via the page composition (most blocks) need no self-fetch at all.

Graph Indexing and Localization #

Two property-level settings control how Graph stores and exposes your content: indexingType determines whether a field can be searched or filtered in Graph queries, and isLocalized tells the CMS to store a separate value per language.

indexingType

Only three values exist. The key constraint: "searchable" and "queryable" are valid on primitive fields only (string, richText, integer, dateTime, boolean). The CMS rejects them on contentReference fields - those only accept "disabled".

valueWhat it enablesApply toExamples in this repo
searchableFull-text search in Graph queriesProse a user would type into a search boxheadline, title, summary, body, bio, question, answer
queryableFilter / sort in Graph queriesMetadata fields - dates, categories, flags, numberspublishDate, category, navOrder, includeInNavigation
disabledExclude from the Graph index entirelyImage contentReferences. Required - binary content cannot be indexed.heroImage, backgroundImage, avatar, photo, logos
indexingType - all three values with notes
// indexingType controls how Graph indexes a property.
// Only three values exist - and not all are valid on every type.

// "searchable" - full-text search. Apply to prose a user would type.
headline:    { type: "string",   displayName: "Headline",     indexingType: "searchable" },
bio:         { type: "richText", displayName: "Author Bio",   indexingType: "searchable" },
question:    { type: "string",   displayName: "Question",     indexingType: "searchable" },

// "queryable" - filter / sort in Graph. Apply to metadata, not prose.
publishDate: { type: "dateTime", displayName: "Publish Date", indexingType: "queryable" },
category:    { type: "string",   displayName: "Category",     indexingType: "queryable" },
navOrder:    { type: "integer",  displayName: "Nav Order",    indexingType: "queryable" },

// "disabled" - exclude from the index. Required on image contentReferences.
// contentReference fields only accept "disabled" - "searchable" / "queryable"
// are not valid on reference types and will be rejected by the CMS on push.
heroImage:   { type: "contentReference", allowedTypes: ["_image"], indexingType: "disabled" },

// omitting indexingType entirely - fine for fields you never query
ctaText:     { type: "string",   displayName: "CTA Text" },

isLocalized

When isLocalized: true is set, the CMS stores a separate value for each language - editors can provide a French headline and an English headline for the same block. Without it, all languages share a single value.

Localize

  • All string fields visible to site visitors (headlines, labels, CTA text, alt text)
  • All richText fields (bio, body, description)
  • json fields containing display text (table columns/rows)

Do NOT localize

  • url fields - the same URL serves all languages
  • boolean, integer, dateTime - structural values
  • Enum discriminators (category, industry) - the key is shared; the display label is separate
  • Technical identifiers (fieldName, rendition, icon)
isLocalized - what to set and what to leave unset
// isLocalized: true - editor stores a separate value per language.
// Add to every field a site visitor reads. Omit from structural fields.

// Localize: all user-visible text
headline:    { type: "string",   displayName: "Headline",  indexingType: "searchable", isLocalized: true },
body:        { type: "richText", displayName: "Body",      indexingType: "searchable", isLocalized: true },
columns:     { type: "json",     displayName: "Columns",                               isLocalized: true },
altText:     { type: "string",   displayName: "Alt Text",                              isLocalized: true },

// Do NOT localize: URLs, booleans, integers, dates, enums, identifiers
ctaLink:     { type: "url",      displayName: "CTA URL" },     // same URL for all locales
highlighted: { type: "boolean",  displayName: "Recommended" }, // structural flag
navOrder:    { type: "integer",  displayName: "Nav Order" },   // sort order
publishDate: { type: "dateTime", displayName: "Publish Date" }, // same timestamp
category:    { type: "string",   displayName: "Category",  indexingType: "queryable" }, // enum key

Gotcha - breaking change

Adding isLocalized: true to an existing field is a breaking schema change. The CMS CLI will refuse to push without --force. Existing content keeps its value in the default locale; other locales start empty. Plan accordingly before enabling localization on a field that already has published content.