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.
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
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.
["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.
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.
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 — full field data requires a self-fetch inside the component
- 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, use the self-fetching pattern: call graphqlFetch directly inside the component when the expected fields are absent.// 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 for type:"content" single references.
// Self-fetch inside the component to get the full field data.
export default async function FaqContainerBlock(props) {
let data = props.content ?? props;
// Graph didn't inline-expand the standalone reference → self-fetch
if (!data.heading) {
const res = await graphqlFetch(FETCH_QUERY, {}, { next: { revalidate: 60 } });
data = res.FaqContainerBlock?.items?.[0] ?? data;
}
// … render
}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 tip
Add indexingType: "searchable" to string fields editors should be able to search via Graph (e.g. heading, quote). Use indexingType: "disabled" for contentReference image fields — Graph can't index binary content and will throw if you omit it.
// string — short text, no formatting
headline: { type: "string", displayName: "Headline", indexingType: "searchable" },
badge: { type: "string", displayName: "Badge Label" },
ctaText: { type: "string", displayName: "Button Label" },// richText — long-form, editor gets a formatting toolbar
// Graph returns { json: {...} } — render with <RichText content={bio.json} />
bio: { type: "richText", displayName: "Author Bio" },
body: { type: "richText", displayName: "Article Body" },// 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 → use self-fetching pattern.
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"] },
},Fragment Co-location #
Each block defines its own GraphQL fragment in a fragment.ts file next to its component. The component "owns" its data shape — adding a property to the content type and fetching it from Graph both happen in the same directory. There is no central "mega-query" that every developer must update.
Content type defines the schema
contentType({ properties: { heading, subheading } }) in index.tsx — the single source of truth for field names and types.
Fragment declares what to fetch
fragment.ts next to the component — lists only the fields this block needs. Graph fetches nothing extra.
Barrel export wires it in
src/lib/graphql/fragments/index.ts exports every fragment. The page query spreads them all in a single request.
// src/components/blocks/SectionHeadingBlock/fragment.ts
export const SECTION_HEADING_FRAGMENT = /* GraphQL */ `
fragment SectionHeadingBlockData on SectionHeadingBlock {
__typename
_metadata { key version }
heading
subheading
}
`;// src/lib/graphql/fragments/index.ts — barrel export
export { SECTION_HEADING_FRAGMENT } from "@/components/blocks/SectionHeadingBlock/fragment";
export { HERO_BLOCK_FRAGMENT } from "@/components/blocks/HeroBlock/fragment";
export { FEATURE_ITEM_FRAGMENT } from "@/components/blocks/FeatureItemBlock/fragment";
// … one line per block
// The page query spreads every fragment in one query — each block gets exactly
// the fields it needs. Adding a new block means adding its fragment here only.Why this scales
A team of 10 can each add a new block without ever touching a shared query file. Each block's fragment is co-located with its component — the same developer who writes the component writes the fragment. The SDK auto-generates the full page query by spreading all registered fragments, so the page route never needs to be updated when a new block is added.