Variation #2Continue reading...

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.

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

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.

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.

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.

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 — 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 — self-fetching pattern
// 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.

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 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
// 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
// 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
// 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 → use self-fetching pattern.
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"] },
},

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.

1

Content type defines the schema

contentType({ properties: { heading, subheading } }) in index.tsx — the single source of truth for field names and types.

2

Fragment declares what to fetch

fragment.ts next to the component — lists only the fields this block needs. Graph fetches nothing extra.

3

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
// 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
// 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.