Developer Demo
Rich Text
How the richText property type works in Optimizely Graph - JSON vs HTML rendering, preview attribute placement, embedded blocks, and when to use rich text vs. a content area.
What Graph returns for a richText field#
Unlike type: "string" which returns a plain scalar, a type: "richText" property returns an object with two representations: json (a structured AST) and html (a pre-rendered HTML string). You choose which to request in your GraphQL fragment and which to render.
// In a content type definition, richText returns an object from Graph,
// not a plain string. The object has two representations: json and html.
export const ArticlePageType = contentType({
key: "ArticlePage",
baseType: "_page",
properties: {
headline: { type: "string" }, // Graph returns: string
body: { type: "richText" }, // Graph returns: { json: RichTextAst, html: string }
},
});
// In the GraphQL fragment - request json for full rendering control,
// html for simpler cases where you just need to inject the markup:
fragment ArticlePageData on ArticlePage {
headline
body {
json # structured AST - use with <RichText> component
html # pre-rendered HTML string - use with dangerouslySetInnerHTML
}
}Two rendering strategies#
The SDK provides a <RichText> server component that renders the JSON AST as semantic HTML. Alternatively, request body.html from Graph and inject it with dangerouslySetInnerHTML. The JSON path is preferred - it supports preview attributes, custom node renderers, and embedded blocks. SDK docs ↗
JSON → <RichText> (recommended)
// src/components/blocks/RichTextBlock/index.tsx
//
// Preferred approach: body.json → <RichText> from the SDK.
// The SDK component renders each node type (paragraph, heading, list, link,
// bold, italic, etc.) as semantic HTML. Customisable via a node renderer map.
import { RichText, type RichTextProps } from "@optimizely/cms-sdk/react/richText";
import { getPreviewUtils } from "@optimizely/cms-sdk/react/server";
export default function TextBlock(props) {
const data = props.content ?? props;
const { pa } = getPreviewUtils(data);
if (data.body && typeof data.body === "object" && "json" in data.body) {
return (
<div {...pa("body")}> {/* ← pa() on the WRAPPER, not on <RichText> */}
<RichText content={data.body.json as RichTextProps["content"]} />
</div>
);
}
// Fallback: body arrived as a raw HTML string (legacy or alternative query)
if (typeof data.body === "string" && data.body) {
return <div {...pa("body")} dangerouslySetInnerHTML={{ __html: data.body }} />;
}
return null;
}HTML → dangerouslySetInnerHTML
// Alternative: body.html → dangerouslySetInnerHTML
// Simpler but less flexible - you cannot customise individual node renderers,
// and the HTML is pre-rendered by Graph with no way to inject React components
// at specific node positions.
export default function ArticleBody({ body }) {
if (!body?.html) return null;
return (
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: body.html }}
/>
);
}
// When to use html vs json:
// html → quick prototypes, simple articles, when you don't need embedded blocks
// json → production rendering, custom node styles, embedded blocks, preview utilitiesbody.json + <RichText>
✓Customisable node renderers
✓Supports embedded blocks
✓Works with pa() preview attributes
✓Can inject React components at specific nodes
–Requires SDK import
–Slightly more setup
body.html + dangerouslySetInnerHTML
✓Minimal code - one line
✓No SDK dependency
–No customisable rendering
–Cannot embed React components
–Preview inline editing doesn't highlight individual nodes
–XSS risk if content source is untrusted
Preview attribute placement - pa("body") on the wrapper#
pa("body") returns a data-epi-block-id attribute that the Visual Builder overlay reads to identify which field you clicked. Place it on the wrapper <div> - never on <RichText> itself. The component renders multiple DOM nodes and cannot receive spread attributes as a single root. SDK docs ↗
// pa("body") MUST go on the wrapper <div>, not on <RichText>.
//
// pa() returns data-epi-block-id attributes that the CMS overlay reads to
// identify which field you clicked on in Visual Builder. <RichText> is a
// function component - it renders multiple DOM elements and has no single
// root node to attach the attribute to.
// ✅ Correct
<div {...pa("body")}>
<RichText content={data.body.json} />
</div>
// ❌ Wrong - pa() attributes are silently swallowed, inline editing breaks
<RichText content={data.body.json} {...pa("body")} />
// Same rule applies to other multi-root renders (lists, grids, etc.) -
// always wrap them before applying pa().Embedded blocks inside body copy#
Editors can embed CMS blocks (callouts, charts, code snippets) directly inside rich text body copy - between paragraphs, not in a content area. Graph returns these as optimizely-content-ref nodes in the JSON AST, each carrying the referenced item's key. Render them by passing a nodeRenderer map to <RichText> that fetches each referenced block and dispatches it through OptimizelyComponent. SDK docs ↗
// Blocks embedded inside rich text body copy are different from content areas.
//
// A CONTENT AREA (type: "array") renders blocks as siblings alongside the text.
// An EMBEDDED BLOCK lives inside the rich text - between paragraphs, inline.
//
// How Graph returns embedded blocks in the JSON AST:
{
"type": "doc",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Our rates:" }] },
{
"type": "optimizely-content-ref", // ← embedded block node type
"attrs": {
"key": "abc123", // reference key
"version": null,
"locale": "en"
}
},
{ "type": "paragraph", "content": [{ "type": "text", "text": "Apply today." }] }
]
}
// To render embedded blocks, pass a custom nodeRenderer to <RichText>:
import { OptimizelyComponent } from "@optimizely/cms-sdk/react/server";
<RichText
content={data.body.json}
nodeRenderer={{
"optimizely-content-ref": async ({ node }) => {
const block = await getClient().getContent({ key: node.attrs.key });
return <OptimizelyComponent content={block} />;
},
}}
/>Rich text vs. content areas - when to use each#
Both type: "richText" and type: "array" (content area) can hold structured content. They model different editorial affordances - rich text is a WYSIWYG document editor, content areas are a drag-and-drop layout builder.
// When to use richText vs. a content area (type: "array"):
// ✅ richText is right for:
// - Longform prose: articles, blog posts, legal pages
// - Mixed text + occasional embedded element (a callout, a chart)
// - Content where editors control precise formatting (bold, italic, lists, links)
// ✅ Content area is right for:
// - Page sections composed of distinct blocks (hero, FAQ, team grid)
// - Multiple blocks of the same type in sequence
// - Any content where editors need drag-to-reorder or display template switching
// ⚠️ Avoid richText when:
// - You need structural layout (the "blocks" are full-width sections, not inline)
// - You have >3 embedded block types - the nodeRenderer grows unwieldy
// - Editors will drag-and-drop between sections - richText has no affordance for that
// Rule of thumb: if it could live in a Word document, richText.
// If it needs a layout builder, use a content area.// richText fields are indexed for full-text search by default -
// body copy is searchable via the _fulltext operator without any extra config.
query SearchArticles($q: String!) {
ArticlePage(
where: { _fulltext: { match: $q } }
orderBy: { _ranking: RELEVANCE }
limit: 10
) {
items {
_metadata { displayName url { default } }
# _fulltext automatically searched body.json content -
# you do not need to include body in this query.
}
}
}
// To exclude body from search (large pages, confidential content):
body: { type: "richText", indexingType: "disabled" }
// ↑ body content won't appear in _fulltext resultsKey Things to Know#
- →richText returns an object, not a string. Request
body { json }orbody { html }in your fragment - not barebody. - →Prefer body.json + <RichText> over body.html. JSON gives you customisable node renderers, embedded block support, and working preview attributes.
- →pa("body") goes on the wrapper div, not on <RichText>. Placing it on the component breaks Visual Builder's inline editing overlay.
- →Embedded blocks use optimizely-content-ref nodes in the AST. Handle them with a nodeRenderer that fetches the block by key and dispatches it through OptimizelyComponent.
- →richText body is indexed for _fulltext search by default. Opt out with
indexingType: "disabled"if the content should not be searchable. - →Use a content area instead of richText for layout sections. If editors need drag-to-reorder or display template switching, a content area is the right model.
Source files1 file
import { contentType, displayTemplate } from "@optimizely/cms-sdk";
import { RichText, type RichTextProps } from "@optimizely/cms-sdk/react/richText";
import { getPreviewUtils } from "@optimizely/cms-sdk/react/server";
import {
TEXT_SIZE, TEXT_ALIGN, FONT_STYLE,
TEXT_SIZE_CLASSES, TEXT_ALIGN_CLASSES, FONT_CLASSES,
} from "../_shared/displayTemplateSettings";
export const TextBlockType = contentType({
key: "TextBlock",
displayName: "Text Block",
baseType: "_component",
compositionBehaviors: ["sectionEnabled", "elementEnabled"],
properties: {
body: { type: "richText", displayName: "Body", isLocalized: true },
},
});
export const TextBlockNarrowTemplate = displayTemplate({
key: "TextBlockNarrowTemplate",
isDefault: false,
displayName: "Narrow layout",
contentType: "TextBlock",
tag: "Narrow",
settings: {
...TEXT_SIZE,
...TEXT_ALIGN,
...FONT_STYLE,
verticalPadding: {
editor: "select" as const,
displayName: "Vertical padding",
sortOrder: 5,
choices: {
default: { displayName: "Standard", sortOrder: 0 },
compact: { displayName: "Compact", sortOrder: 1 },
spacious: { displayName: "Spacious", sortOrder: 2 },
},
},
},
});
interface TextBlockData {
body?: { json: unknown } | string | null;
__context?: { edit?: boolean } | null;
}
type TextBlockProps = TextBlockData & {
content?: TextBlockData;
displaySettings?: Record<string, string | boolean>;
displayTemplateKey?: string;
};
const PADDING_CLASSES: Record<string, string> = {
default: "py-16",
compact: "py-8",
spacious: "py-24",
};
export default function TextBlock(props: TextBlockProps) {
const data = props.content ?? props;
const ds = props.displaySettings;
const { pa } = getPreviewUtils(data as any);
const isNarrow = props.displayTemplateKey === "TextBlockNarrowTemplate";
const paddingClass = PADDING_CLASSES[(ds?.verticalPadding as string) ?? "default"] ?? "py-16";
const alignClass = TEXT_ALIGN_CLASSES[(ds?.textAlign as string) ?? "left"] ?? "text-left";
const fontClass = FONT_CLASSES[(ds?.fontStyle as string) ?? "classic"];
const textSizeClass = TEXT_SIZE_CLASSES[(ds?.textSize as string) ?? "md"] ?? "text-base";
const widthClass = isNarrow ? "max-w-2xl" : "max-w-4xl";
const containerClass = `${widthClass} mx-auto px-8 ${paddingClass} text-on-surface-variant ${alignClass}`;
if (data.body && typeof data.body === "object" && "json" in data.body && data.body.json) {
return (
<div data-component="TextBlock" {...pa("body")} className={containerClass}>
<div className={`${fontClass} ${textSizeClass} leading-relaxed space-y-6`}>
<RichText content={data.body.json as RichTextProps["content"]} />
</div>
</div>
);
}
if (typeof data.body === "string" && data.body) {
return (
<div
data-component="TextBlock"
{...pa("body")}
className={`${containerClass} ${fontClass} ${textSizeClass} leading-relaxed`}
dangerouslySetInnerHTML={{ __html: data.body }}
/>
);
}
return null;
}