Developer Demo
Content Reuse
Referenced vs. embedded content - when one update should propagate everywhere, when copies are intentional, and how Graph handles each in queries.
Four ways to relate content#
The SDK offers four property types for attaching content to a parent. 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 returns only _metadata (key, url) - fields are NOT inline-expanded.
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 term | SDK type | When Graph expands it | Can create inline? |
|---|---|---|---|
| Content Area | array (items: { type: "content" }) | Always - all items inlined | Yes |
| Content Area Item (standalone) | content | Never - _metadata only | Yes |
| Content Reference | contentReference | Only with specific contentType | No |
| Component / Block (in UI) | component | Always (no _metadata wrapper) | Stored inside parent |
Content Area
What Visual Builder calls the drop zone where editors add blocks. Backed by a type: "array" property. Each block dropped into it is a Content Area Item. Editors drag-and-drop to reorder. Items can be newly created inline blocks (no separate CMS record) or existing items pulled in from the content tree.
Content Area Item
Two meanings depending on context. (1) Each individual block inside a Content Area. (2) A standalone type: "content" property - a single editable slot. In both cases, editors can create new inline or pick existing. Standalone items return only _metadata from Graph.
Content Reference
A type: "contentReference" property. Editors can only pick existing content - there is no create-inline option. Most common use is images and DAM assets. The only reference type that works on elementEnabled blocks.
Content Area deep dive#
A Content Area (type: "array") is an ordered, editable list of blocks. Understanding its constraints and how Graph handles it determines how you define and query content area properties.
Definition with constraints
// Full content area definition with constraints
export const FaqContainerBlockType = contentType({
key: "FaqContainerBlock",
baseType: "_component",
compositionBehaviors: ["sectionEnabled"], // sectionEnabled required for arrays
properties: {
heading: { type: "string" },
faqItems: {
type: "array",
items: {
type: "content",
allowedTypes: [FaqItemBlockType], // restricts what editors can drop in
},
displayName: "FAQ Items",
minItems: 1, // editor must add at least one item
maxItems: 20, // upper bound
},
},
});Querying with inline fragments
# Graph query for a content area
# Graph inlines all items - use inline fragments to access typed fields.
# __typename tells you which concrete type each item is.
query {
FaqContainerBlock(limit: 1) {
items {
heading
faqItems {
__typename
... on FaqItemBlock {
question
answer
}
}
}
}
}allowedTypes
Restricts which content types editors can drop in. Set on the items object. Without it, any content type is allowed.
items: { type: "content", allowedTypes: [FaqItemBlockType] }minItems / maxItems
Validation constraints. minItems makes the area required. maxItems caps how many blocks editors can add.
minItems: 1, maxItems: 20sectionEnabled required
Content areas are silently ignored on elementEnabled blocks. The block must have sectionEnabled in compositionBehaviors.
compositionBehaviors: ["sectionEnabled"]Graph inlining
Graph inlines all items in a single query. No extra fetch needed. Use __typename + inline fragments to access typed fields.
... on FaqItemBlock { question answer }Inline items vs referenced items inside a Content Area
When an editor drops a block into a Content Area in Visual Builder, the block can be either:
- 1.A new inline block - created inside this area with no independent CMS record. Editing it affects only this parent. Deleting the parent deletes the block. This is the default when an editor clicks "Add block" and fills in fields directly.
- 2.An existing referenced item - an item that already exists in the CMS, picked from the content tree. Editing that item propagates to every Content Area that references it. This is the shared-content pattern.
Both appear identically in Graph - the full typed fields arrive either way. The difference is purely editorial: only the referenced items have their own independent lifecycle in the CMS.
Content Area Item vs Content Reference#
type: "content" and type: "contentReference" look similar in the SDK - both accept allowedTypes and contentType - but they behave differently for editors and produce different Graph responses.
type: "content" - Content Area Item
- -Editor can create new inline blocks or pick existing ones
- -Graph always returns only
_metadatafor standalone properties - regardless ofcontentTypeorallowedTypes - -Component must self-fetch to get typed field data
- -Not available on
elementEnabledblocks - -Use when editors need to build or configure the linked block themselves
type: "contentReference" - Content Reference
- -Editor can only pick existing content - no create-inline option
- -Graph behavior depends on configuration: specific
contentTypereturns full object;allowedTypesreturns only_metadata - -Available on
elementEnabledblocks (the only reference type that works) - -Use for images, DAM assets, and typed references where Graph inline-expansion is useful
contentReference with specific contentType - Graph expands
// contentReference with a specific contentType
// Graph returns the full typed object - no self-fetch needed.
featuredAuthor: {
type: "contentReference",
contentType: AuthorBlockType, // typed - Graph expands to full AuthorBlock fields
displayName: "Featured Author",
}
// Usage in the component - fields arrive directly:
export default function ArticlePage({ content }) {
const author = content.featuredAuthor; // full fields, no extra fetch
return <div>{author.name} - {author.bio}</div>;
}contentReference with allowedTypes - metadata only
// contentReference with allowedTypes
// Graph returns _metadata (key + url) only - same as type: "content" standalone.
// Use for images and DAM assets where you only need the URL.
backgroundImage: {
type: "contentReference",
allowedTypes: ["_image"],
indexingType: "disabled", // required for image references
}
// In the component - only _metadata.url is available:
const bgUrl = content.backgroundImage?._metadata?.url?.default;elementEnabled blocks
type: "content" and type: "array" are silently ignored on elementEnabled blocks - only type: "contentReference" works there. If you need a reference on a leaf block (e.g., a background image on a hero), contentReference is the right choice. To have content areas, the block must have sectionEnabled in its compositionBehaviors.
Referenced content - single source of truth#
When multiple pages reference the same content item, updating that item propagates to all of them automatically after the next publish and ISR revalidation. No individual page edits required. This is the key benefit of type: "content", type: "array", and type: "contentReference" - the referenced item has its own independent identity and lifecycle in the CMS.
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.
Edit count: 1. Pages updated: 12.
Embedded - copied on save
The same promo block 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.
Content drift#
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 called content drift - and it's an editorial risk, not a developer bug.
A real scenario
- 1.A legal disclaimer appears on 40 product pages. Each page has its own embedded copy.
- 2.Legal sends a correction. A developer updates the disclaimer on 3 pages and marks the ticket done.
- 3.The remaining 37 pages still show the old, incorrect disclaimer.
- 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.
Rule of thumb: if two pages showing different text for the same thing would be an editorial error, model it as a reference - not an embedded component.
Resolving single references in components#
type: "content" standalone properties are not inline-expanded by Graph. The page query returns only _metadata - no typed fields. The component must resolve the full item separately. Two patterns exist: the self-fetch guard (detect missing data, call graphqlFetchdirectly) and the getContent() SDK method introduced in SDK 2.0.0. SDK docs ↗
Pattern A - self-fetch guard
// src/components/blocks/FaqContainerBlock/index.tsx
//
// FaqContainerBlock placed as a single reference receives only _metadata
// from the page query. The guard clause detects this and self-fetches.
export default async function FaqContainerBlock(props) {
let data = props.content ?? props;
if (!data.heading) {
const res = await graphqlFetch(FETCH_QUERY, {}, { next: { revalidate: 60 } });
data = res.data?.FaqContainerBlock?.items?.[0] ?? data;
}
return <div>...</div>;
}Pattern B - getContent() by key
// SDK 2.0.0 alternative: getContent() by key or graph:// reference.
// _metadata.key and _metadata.url.graph are always present on references.
import { getClient } from "@optimizely/cms-sdk";
export default async function FeaturedBlock({ content }) {
let data = content;
if (!data.headline && data._metadata?.key) {
// Option A - fetch by CMS key
data = await getClient().getContent({ key: data._metadata.key });
}
// Option B - fetch by graph:// URL directly
// data = await getClient().getContent(data._metadata.url.graph);
return <div>{data.headline}</div>;
}
// Add previewToken for draft content:
// data = await getClient().getContent({ key }, { previewToken: token });type: "contentReference" with a specific contentType.When contentReference has a typed contentType (not allowedTypes), Graph returns the full object - no self-fetch needed. The self-fetch patterns above apply only to standalone type: "content" properties.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 between type: "content" and type: "contentReference"
Both are references to independent items. Use type: "contentReference" when the linked item always exists independently (an image, a DAM asset, a typed block you want Graph to expand). Use type: "content" when editors need the flexibility to create a new block inline or pick an existing one. Remember: contentReference with a specific contentType will give you full Graph expansion - no self-fetch required.
Key Things to Know#
- →type: "content" standalone properties always return only _metadata from Graph. Fields like heading, body, and image are never inline-expanded regardless of allowedTypes or contentType. Self-fetch or use getContent() to get them.
- →type: "contentReference" with a specific contentType returns the full object from Graph. No self-fetch needed. With allowedTypes (multiple types), Graph returns only _metadata - same behavior as type: "content".
- →type: "array" content areas ARE inline-expanded. All item fields arrive with the page query. Use
__typenameand... on TypeNameinline fragments to access typed fields. - →elementEnabled blocks can only use type: "contentReference" for content relationships. type: "content" and type: "array" are silently ignored on elements. The block must have sectionEnabled to support content areas.
- →Content Area Items can be inline or referenced - Graph treats them the same. An inline block (created in the area, no separate CMS record) and a referenced item (pulled from the content tree) both arrive as full typed objects in Graph. The difference is editorial lifecycle only.
- →Referenced content = single source of truth. Update the referenced item once - every page that points to it reflects the change on next ISR revalidation.
- →type: "component" copies data into the parent on save. Editing a component in one parent does not affect other parents. Use this for per-page configuration, not shared content.
- →getContent() accepts a graph:// URL directly.
_metadata.url.graphis always populated on references - pass it straight to getClient().getContent() without a separate key lookup.
Source files1 file
import { contentType } from "@optimizely/cms-sdk";
import { OptimizelyComponent, getPreviewUtils } from "@optimizely/cms-sdk/react/server";
import { FaqItemBlockType } from "@/components/blocks/FaqItemBlock";
import { BlockErrorBoundary } from "@/components/cms/BlockErrorBoundary";
export const FaqContainerBlockType = contentType({
key: "FaqContainerBlock",
displayName: "FAQ Container",
baseType: "_component",
compositionBehaviors: ["sectionEnabled"],
properties: {
heading: { type: "string", displayName: "Heading", indexingType: "searchable", isLocalized: true },
subheading: { type: "string", displayName: "Subheading", indexingType: "searchable", isLocalized: true },
faqItems: { type: "array", items: { type: "content", allowedTypes: [FaqItemBlockType] }, displayName: "FAQ Items" },
},
});
interface FaqItemData {
__typename?: string;
question?: string | null;
answer?: string | null;
}
interface FaqContainerData {
heading?: string | null;
subheading?: string | null;
faqItems?: (FaqItemData | unknown)[] | null;
__context?: { edit?: boolean } | null;
}
type FaqContainerBlockProps = FaqContainerData & {
content?: FaqContainerData;
displaySettings?: Record<string, string | boolean>;
};
export default function FaqContainerBlock(props: FaqContainerBlockProps) {
const data: FaqContainerData = props.content ?? props;
const { pa } = getPreviewUtils(data as any);
return (
<div data-component="FaqContainerBlock" className="py-16 max-w-3xl mx-auto px-8">
{data.heading && (
<h2
{...pa("heading")}
className="font-display text-3xl md:text-4xl font-extrabold mb-3 text-on-surface"
>
{data.heading}
</h2>
)}
{data.subheading && (
<p
{...pa("subheading")}
className="text-base text-on-surface-variant mb-8"
>
{data.subheading}
</p>
)}
{data.faqItems && data.faqItems.length > 0 && (
<div {...pa("faqItems")} className="space-y-2">
{data.faqItems.map((item, i) => (
<BlockErrorBoundary key={i}>
<OptimizelyComponent content={item as any} />
</BlockErrorBoundary>
))}
</div>
)}
</div>
);
}