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.

Content type definition + GraphQL fragment
// 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
// 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 - simpler but less flexible
// 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 utilities

body.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() placement - wrapper div, not RichText component
// 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 ↗

optimizely-content-ref node + custom nodeRenderer
// 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.

Decision guide: richText vs content area
// 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 _fulltext search
// 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 results

Key Things to Know#

  • richText returns an object, not a string. Request body { json } or body { html } in your fragment - not bare body.
  • 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
src/components/blocks/RichTextBlock/index.tsx
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;
}