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.
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 childrenExperience
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 typeDynamicExperience - 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 usingOptimizelyComposition- 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
_pagebase typeTraditionalPage - 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 usinggetClient().getContent().
Examples in this repo
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.
| Dimension | DynamicExperience (_experience) | TraditionalPage (_page) |
|---|---|---|
| Layout owner | Editor in Visual Builder | Developer in React |
| Graph payload | composition.nodes tree (full block data) | Typed content fields (heading, body, etc.) |
| Single references | N/A - blocks are inline in composition | Base metadata only - resolve in page component |
| Ideal for | Marketing pages, campaign pages, homepages | Articles, profiles, structured documents |
| Editor autonomy | High - editor controls block order and layout | Low - 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.
// 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" },
},
});// 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
// Good - describes what the content IS
export const TestimonialBlockType = contentType({ key: "TestimonialBlock", … });
export const PricingTierBlockType = contentType({ key: "PricingTierBlock", … });
export const SectionHeadingBlockType = contentType({ key: "SectionHeadingBlock", … });// 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
// 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 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 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
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)
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.
| type | Use when | Graph returns | Examples |
|---|---|---|---|
| string | Short text, no formatting needed | Plain string | title, ctaText, badge, value |
| richText | Long-form - editors need bold, links, headings | { json: {...} } - render with <RichText> | bio, body, description |
| url | Links and external URLs | { default: "https://…" } | ctaLink, linkedinUrl |
| contentReference | Single image or content item | Base metadata only (_metadata.url) | authorImage, backgroundImage |
| array | Ordered list of blocks (content area) | Full inline-expanded objects | faqItems, logos, navItems |
Indexing and localization
See the Graph Indexing and Localization section below for a full guide on indexingType values and isLocalized.
// 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 - 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 - Graph returns { default: "https://…" }
// Unwrap with: const href = value?.default ?? value
ctaLink: { type: "url", displayName: "Button URL" },
linkedinUrl: { type: "url", displayName: "LinkedIn Profile" },// 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 - 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 ↗
SDK fetches the page automatically
client.getContentByPath() retrieves the full composition. Inline blocks receive all their fields - no extra work required.
Referenced content returns keys only
Single contentReference properties are not inline-expanded. The component receives base metadata - key, URL - not the full fields.
Call getContent() with the key
getClient().getContent({ key }) fetches the full item. No manual GraphQL query. Works for single references and reference arrays alike.
// 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 } }
);// 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".
| value | What it enables | Apply to | Examples in this repo |
|---|---|---|---|
| searchable | Full-text search in Graph queries | Prose a user would type into a search box | headline, title, summary, body, bio, question, answer |
| queryable | Filter / sort in Graph queries | Metadata fields - dates, categories, flags, numbers | publishDate, category, navOrder, includeInNavigation |
| disabled | Exclude from the Graph index entirely | Image contentReferences. Required - binary content cannot be indexed. | heroImage, backgroundImage, avatar, photo, logos |
// 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
stringfields visible to site visitors (headlines, labels, CTA text, alt text) - All
richTextfields (bio, body, description) jsonfields containing display text (table columns/rows)
Do NOT localize
urlfields - the same URL serves all languagesboolean,integer,dateTime- structural values- Enum discriminators (
category,industry) - the key is shared; the display label is separate - Technical identifiers (
fieldName,rendition,icon)
// 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 keyGotcha - 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.