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

Content Model Strategies #

Before choosing base types or composition behaviors, you pick an approach for discovering the model itself. Every approach serves the same two principles: separate content from presentation (so the design can change without re-modelling) and design for reuse (so a block built once works everywhere). There are three common approaches. Docs ↗

Top-down

Start from a high-level overview - the main content types you need (marketing pages, articles, product pages) - then work down into each one's sections and fields, one level at a time.

When: content categories and hierarchy are already clear and well-defined.

Tradeoff: gives a clear structure fast, but can miss small details that only show up during the build.

LandingPage ArticlePage BusinessBankingPage

Bottom-up

Begin at the smallest level - a button, a heading, a stat - and group those small building blocks up into larger blocks and, eventually, whole pages.

When: starting from existing assets, or when field-level requirements are well understood.

Tradeoff: slower to show a whole page early, but yields flexible, highly reusable components.

ButtonBlock SectionHeadingBlock HeroBlock

Hybrid - recommended

Sketch the major content types top-down, then detail each one bottom-up from reusable elements - and iterate between the two as you learn.

When: most projects - it balances architectural clarity with practical build concerns.

Tradeoff: you go back and forth as you learn, but it avoids the downsides of the other two.

DynamicExperience HeroBlock TestimonialBlock

StrategyStart fromBest whenTradeoff
Top-downHigh-level content types (pages)Categories and hierarchy already clearFast clarity, can miss field-level detail
Bottom-upAtomic reusable elementsExisting assets or well-known fieldsReusable parts, slower to a whole page
HybridBoth - pages first, then elementsMost projectsNeeds iteration, lowest overall risk

The hybrid approach in practice - how this repo's Mosey Bank model was built:

1

Map the pages (top-down)

List the page types Mosey Bank needs - Experience marketing pages, plus ArticlePage, CaseStudyPage and TeamMemberPage for structured content.

2

Factor out elements (bottom-up)

Pull the pieces those pages share into reusable blocks - HeroBlock, StatsCounterBlock, TestimonialBlock and ButtonBlock - each modelled once and reused everywhere.

3

Wire together and iterate

Assign each block its compositionBehaviors and slot it into the three-tier model, refining fields as real content lands.

It all serves the same two principles

Whichever strategy you pick, the goal is the same: keep content separate from presentation (styling lives in display templates, not content types) and design for reuse (model a concept once, reference it from many pages). The mechanics for both are covered in the sections below - starting with the three-tier model.

The Three-Tier Model #

Every piece of content in Visual Builder lives at one of three levels. Knowing these levels tells you which compositionBehaviors to give a block, and how editors build pages - before you write 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) ← a single block with nothing inside it

Experience

The page itself. Sets the URL, language, SEO metadata, and overall layout. 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 single content block with nothing inside it. Editors place it inside sections 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 it accepts any elementEnabled block rather than a fixed list of allowed types.

Page Types - Experience vs Page #

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

Experience - editor-owned layout

  • The editor assembles the page in Visual Builder by placing blocks into sections and columns.
  • The React component (DynamicExperience) renders the layout the editor built 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

Page - 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: "contentReference" fields (e.g. author, images) return just { key, url } from Graph - fetch the full item in the page component using getClient().getContent(). type: "content" fields, by contrast, arrive fully expanded.

Examples in this repo

ArticlePageCaseStudyPageTeamMemberPage

How both page types reach the screen

Both page types go through the same OptimizelyComponent - the part that picks the right React component to render. The catch-all route calls getContentByPath(), which returns either a DynamicExperience or a TraditionalPage (or any other registered type). OptimizelyComponent reads __typename and sends it to the matching React component - so there is no if/switch on the type in the route. The key difference is what that component does with its content prop - and it is only one extra step. Both then render components. An Experience first loops over content.composition.nodes (the blocks the editor arranged) and renders the component for each one. A Page skips that loop and renders its component straight from named fields like content.heading and content.body. In this app that means React components; on a native app or another framework it is whatever view that platform renders.

How each type renders

Experience

Graph returns the saved layout

Loop over  composition.nodes

Render each block's component

HTML page

Page

Graph returns the content fields

no loop needed

Render the component from fields

HTML page

Both paths end in the same step - rendering the required components. The only difference is that an Experience loops over the blocks the editor arranged first, while a Page renders straight from its fields.

DimensionExperience (_experience)Page (_page)
Layout ownerEditor in Visual BuilderDeveloper in React
What Graph sends backThe full layout the editor built, with all block dataThe page's content fields (heading, body, etc.)
Single referencesN/A - blocks are built into the layoutJust the basics - fetch the full item in the page component
Ideal forMarketing pages, campaign pages, homepagesArticles, profiles, structured documents
Editor controlHigh - editor controls block order and layoutLow - layout is fixed in code

Governance - Editor Flexibility vs Lockdown #

A content model is also a set of guardrails. Too loose and editors can break brand and layout consistency; too tight and every small change needs a developer. Good modelling opens up the content - the copy and imagery editors own - while locking down the structure - the layouts and block types the brand depends on. SDK docs ↗

← Editor flexibilityDeveloper control →

Most editor freedom

An Experience with broad content areas. Editors control layout, block order, and which blocks appear in Visual Builder.

Experience

Freedom within guardrails

An Experience with curated allowedTypes and a fixed set of display-template settings. Editors compose freely, but only from an approved palette.

allowedTypes displayTemplate

Most developer control

A Page with a fixed React layout. Editors only fill in named content fields - the structure is owned entirely in code.

Page
MechanismWhat it constrainsGovernance effect
Page-type choice (_experience vs _page)Who owns the page layoutEditor-driven layout vs a developer-fixed one
compositionBehaviorsWhere a block may be placed (element vs section)Stops editors nesting the wrong things in the wrong slots
mayContainTypesWhich child content types a page or folder may holdEnforces the intended information architecture
Content-area allowedTypesWhich blocks fit a specific type: array slotA testimonials section only accepts TestimonialBlock
Display-template settings (choices)Which visual options an editor seesBounded, plain-English choices instead of free CSS

Do - lock structure, open content

  • Fix the layout of transactional and legal pages where consistency is non-negotiable
  • Restrict content areas to the block types that belong there via allowedTypes
  • Offer visual variety through curated display-template choices, not free-form styling
  • Leave the actual copy, imagery, and ordering to editors

Avoid - the two failure modes

  • Too open - every block allowed everywhere. Editors can build off-brand, broken layouts, and no one can reason about a page's shape.
  • Too locked - a developer is needed for every text tweak. Editors are blocked, and the CMS stops earning its keep.

Governance is strategy made concrete

These mechanisms are how you deliver flexibility within guardrails. Your chosen content model strategy decides what to model; governance decides how much control editors get over each piece of it.

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"]

A single block with nothing inside it. Cannot have a type: "array" content area property - the CMS will silently ignore it. Editors place it inside sections.

["sectionEnabled"]

A container only. Can have type: "array" content areas. Cannot be placed inside another section. The SDK lays out the child blocks for you 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. A single block with nothing inside it → elementEnabled. Unsure → both.

elementEnabled - a standalone 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? It comes down to whether the fields are different. 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 includes the full data for 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 just { key, url } for a contentReference - fetch the full data in the parent page component using getClient().getContent()
  • Examples: AuthorBlock linked from 10 articles via author, a hero image reused across pages

Gotcha

The type that returns only the basics is type: "contentReference", not type: "content". A type: "content" reference is inline-expanded by Graph - the SDK auto-generates a fragment for every allowed component type, so the block arrives fully typed in the page query with no extra fetch, whether it is a single reference or a type: "array" content area. Only type: "contentReference" comes back as just { key, url } - for those, resolve the full item in the parent page component using getClient().getContent({ key }) before passing it down. Never add self-fetch logic inside the block itself.
type: content / array - Graph inline-expands, no fetch
// 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] },
    },
  },
});
type: contentReference - parent resolves via getClient().getContent()
// type: "contentReference" - the one type Graph does NOT inline-expand.
// The field is typed "ContentReference" in Graph: you get { key, url } only,
// and inline fragments are rejected. Resolve the full item in the PARENT.

// src/components/pages/ArticlePage.tsx
export default async function ArticlePage({ content }) {
  // content.author is a type:"contentReference" - only { key, url } arrived
  const author = content.author?._metadata?.key
    ? await getClient()
        .getContent({ key: content.author._metadata.key }, { next: { revalidate: 300 } })
        .catch(() => null)
    : null;

  return (
    // author is now the full AuthorBlock - render its name, bio, avatar
    <article>
      {author && <OptimizelyComponent content={author} />}
    </article>
  );
}

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 on its own. Editing it once updates every article that references it. Graph returns just the basics - the parent page fetches the full item via getClient().getContent() before rendering.

The four ways to relate content #

The inline-vs-referenced choice above is really a spectrum of four property types. They differ in how tightly the child is coupled to its parent, how Graph resolves them in queries, and whether editing the child affects every parent that uses it. SDK docs ↗

type: "component"

The child is stored inside the parent record. It has no independent identity in the CMS - it only exists as part of this parent.

Graph: Inline-expanded automatically

Example: CTA button on a hero block

// type: "component" - inline embed
// The child is stored inside the parent record. No independent CMS identity.
cta: {
  type: "component",
  contentType: ButtonComponentType,
  displayName: "CTA Button",
}

type: "content"

A single slot for an independent item. The editor can pick an existing item or create a new inline block. Updating the referenced item affects every parent.

Graph: Returns _metadata only - must self-fetch

Example: Featured FAQ on a product page

// type: "content" - Content Area Item (single slot)
// The parent stores a pointer to an independent content item.
// Graph DOES inline-expand it - the SDK generates a fragment per allowed
// type, so all fields arrive in the page query. No self-fetch needed.
featuredBlock: {
  type: "content",
  allowedTypes: [FaqContainerBlockType],
  displayName: "Featured FAQ Block",
}

type: "array"

An ordered list of Content Area Items. Editors add blocks via drag-and-drop. Graph does inline-expand these - all fields arrive with the page query.

Graph: Inline-expanded automatically

Example: FAQ items list, logo grid

// type: "array" - Content Area (ordered list)
// Graph DOES inline-expand these - all item fields arrive in the page query.
faqItems: {
  type: "array",
  items: { type: "content", allowedTypes: [FaqItemBlockType] },
  displayName: "FAQ Items",
}

type: "contentReference"

A reference to an existing item only - editors pick from the content tree, they cannot create inline. The only reference type available on elementEnabled blocks.

Graph: Full object (specific type) or _metadata (allowedTypes)

Example: Background image on a hero

// type: "contentReference" - reference to existing content only
// Editors pick from the content tree - they cannot create inline.
// With allowedTypes: Graph returns _metadata (key + url) only.
// With contentType (specific type): Graph returns the full object.
backgroundImage: {
  type: "contentReference",
  allowedTypes: ["_image"],
  displayName: "Background Image",
  indexingType: "disabled",
}

CMS terminology vs SDK types

The Optimizely CMS UI and the official docs use different names than the SDK property types. This table maps them so you can read either without confusion.

CMS UI / docs termSDK typeWhen Graph expands itCan create inline?
Content Areaarray (items: { type: "content" })Always - all items inlinedYes
Content Area Item (standalone)contentAlways - fully inlined via fragmentYes
Content ReferencecontentReferenceOnly with specific contentTypeNo
Component / Block (in UI)componentAlways (no _metadata wrapper)Stored inside parent

Content drift and single source of truth #

The reason the reference-vs-embed choice matters editorially: embedded content diverges over time. The more pages that hold their own copy of a piece of content, the higher the chance that some copies get updated and others don't. This is content drift - an editorial risk, not a developer bug.

Referenced - single source of truth

A promo block is referenced from 12 landing pages. The marketing team updates the offer text once in the CMS. All 12 pages show the new text after the next publish and ISR revalidation.

Edit count: 1. Pages updated: 12.

Embedded - copied on save

The same promo is embedded as a type: "component" on each page. To update the offer text, an editor must open and re-publish all 12 pages individually.

Edit count: 12. Risk of inconsistency: high.

A real scenario

  1. 1.A legal disclaimer appears on 40 product pages. Each page has its own embedded copy.
  2. 2.Legal sends a correction. A developer updates the disclaimer on 3 pages and marks the ticket done.
  3. 3.The remaining 37 pages still show the old, incorrect disclaimer.
  4. 4.Six months later, no one knows which pages are correct - there are now multiple versions in the wild.

The fix: model the disclaimer as a single referenced content item. Legal updates it once - all 40 pages reflect the change automatically.

When to reference vs embed

Content Area (type: "array") when:

  • -An ordered list of blocks editors assemble themselves
  • -Items need drag-and-drop reordering in Visual Builder
  • -Each item has its own fields editors fill in
  • -Examples: FAQ list, logo grid, feature items, team members

Reference (type: "content" / type: "contentReference") when:

  • -The same item appears on multiple pages
  • -Editors need to update it once and see it everywhere
  • -The item has its own editorial lifecycle (draft, review, publish)
  • -Examples: shared promo, legal disclaimer, author bio, featured FAQ

Embed (type: "component") when:

  • -The child is specific to this parent - no meaning outside it
  • -Editors configure it per-parent, not from a shared library
  • -It changes alongside the parent and only the parent
  • -Examples: CTA button on a hero block, price badge on a product card

Choosing the Right Property Type #

Each property type gives the editor a different input in the CMS, and comes back in a different shape from Graph. Choosing the right one affects both the editing experience 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 itemJust the basics (_metadata.url)authorImage, backgroundImage
arrayOrdered list of blocks (content area)The full data, included automaticallyfaqItems, 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 include the full data for single type: "contentReference" properties - the component receives just the basics (the item's key). To get the 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 don't include the full data. The component receives just the basics - 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 already arrives in full through the page layout (most blocks) need no self-fetch at all.

Graph Indexing and Localization #

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

indexingType

Only three values exist. The main rule: "searchable" and "queryable" work on basic value 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 - behind-the-scenes values
  • Fixed-choice keys (category, industry) - the key is shared; the label shown to visitors is stored separately
  • 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.