Show navigationHide navigation

Rich Text Field

The richtext field type stores content as companion .mdx files (MDX — Markdown with JSX). It extends standard Markdown with embeddable CMS blocks: images from the media library, references to other entries, conditional content branches, template variables, and custom React components.

Quick start#

typescript
// cms/octocms.config.ts
{
  body: {
    label: 'Body',
    format: 'richtext',
  },
}

This gives you a full WYSIWYG editor with headings, lists, bold/italic, tables, code blocks, links, and images. Content is stored in a companion .mdx file alongside the entry JSON (e.g. post-123.body.mdx).

Embeds#

Enable embeddable content types via the richtext.embeds config:

typescript
body: {
  label: 'Body',
  format: 'richtext',
  richtext: {
    embeds: {
      images: true,
      references: { collections: ['post', 'author'], display: 'both' },
      conditions: true,
      variables: ['user.firstName', 'site.name'],
      components: {
        CallToAction: {
          label: 'Call to Action',
          kind: 'block',
          props: [
            { name: 'text', label: 'Button text', type: 'string', required: true },
            { name: 'url', label: 'Link URL', type: 'url', required: true },
          ],
        },
      },
    },
  },
},

Image embeds (embeds.images)#

When enabled, adds a toolbar button and slash command to insert images from the media library. Stored as <CmsImage mediaId="uuid" /> in MDX. Resolved by query() to a ResolvedImageField object (same shape as the image field type).

Reference embeds (embeds.references)#

Embed other content entries inline or as blocks. Configure which collections are allowed and whether references appear 'inline', 'block', or 'both'.

Stored as <CmsRef id="post-456.json" display="block" />. Resolved by query() to full entry objects (deep resolution, same as reference fields).

Condition embeds (embeds.conditions)#

Insert conditional (A/B) content blocks with named branches:

mdx
<CmsCondition field="promo">
  <CmsBranch key="control">Standard pricing.</CmsBranch>
  <CmsBranch key="test">**50% off** this week!</CmsBranch>
</CmsCondition>

At render time, pass a conditions map to select which branch to display.

Template variables (embeds.variables)#

Insert dynamic placeholders that are substituted at render time. Only variable names listed in the config array are available in the editor.

Stored as <CmsVar name="user.firstName" />. Rendered by passing a variables map to RichTextContent.

Custom components (embeds.components)#

Define arbitrary React components that editors can insert. Each component has:

  • label — display name in the editor
  • kind'inline' (within text) or 'block' (own line)
  • props — typed prop definitions (string, number, boolean, url, image, select)

Stored as standard JSX: <CallToAction text="Sign up" url="/signup" />.

Toolbar customization#

Control which standard toolbar buttons appear:

typescript
body: {
  label: 'Body',
  format: 'richtext',
  richtext: {
    toolbar: {
      formatting: true,    // Bold, italic, underline (default: true)
      headings: true,       // Block type selector (default: true)
      lists: true,          // Ordered/unordered lists (default: true)
      code: true,           // Inline code toggle (default: true)
      codeBlock: true,      // Code block insertion (default: true)
      links: true,          // Link creation (default: true)
      tables: true,         // Table insertion (default: true)
      thematicBreak: true,  // Horizontal rule (default: true)
      images: true,         // Standard markdown images (default: true)
      undoRedo: true,       // Undo/redo buttons (default: true)
    },
  },
},

Set any key to false to hide that toolbar section. Omitted keys default to true.

Slash commands#

Type / in the editor to open the insert menu. It lists all available block types and embed types (filtered by your richtext config). Type to filter, arrow keys to navigate, Enter to select, Escape to close.

Storage format#

Rich text content is stored as companion .mdx files alongside the entry JSON:

cms/content/post/
  post-123.json            # structured fields
  post-123.body.mdx        # rich text content

Standard Markdown is valid MDX, so the format is backward-compatible. Embeds are stored as JSX tags.

Reading richtext on the public site (Server Components)#

Use query() like any other field. For richtext fields, query() reads the companion .mdx file, parses it to a RichTextDocument AST, and resolves embedded images and references.

tsx
import { query } from 'octocms/query';
import RichTextContent from 'octocms/components/public/RichTextContent';
import { CallToAction } from '@/components/CallToAction';

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await query('post')
    .filter({ slug: params.slug })
    .first();

  if (!post) return null;

  return (
    <article>
      <h1>{post.fields.title}</h1>
      <RichTextContent
        document={post.fields.body}
        components={{ CallToAction }}
        variables={{ 'user.firstName': 'Guest' }}
        conditions={{ promo: 'control' }}
      />
    </article>
  );
}

RichTextContent props#

PropTypeDescription
documentRichTextDocument | nullThe AST from query().
componentsRecord<string, ComponentType>Map custom component names to React components.
variablesRecord<string, string>Variable substitutions for <CmsVar> nodes. Unmatched variables render as {name}.
conditionsRecord<string, string>Map condition field names to the branch key to render. Unmatched conditions render nothing.
classNamestringOptional wrapper class name.

Custom component rendering#

Custom components receive their configured props plus optional children:

tsx
function CallToAction({ text, url }: { text: string; url: string }) {
  return (
    <a href={url} className="btn btn-primary">
      {text}
    </a>
  );
}

Reference rendering#

By default, embedded references render with a fallback display (type label + title). To customize, pass a component keyed by collection name:

tsx
<RichTextContent
  document={post.fields.body}
  components={{ post: PostCard, author: AuthorBadge }}
/>

Reference components receive { entry, display } where display is 'inline' or 'block'.

Reading richtext on the client (CSR)#

If you need client-side rendering, the RichTextContent component works in client components too — it's a pure React component, no server-only imports:

tsx
'use client';

import RichTextContent from 'octocms/components/public/RichTextContent';

export function RichTextPreview({ document }) {
  return <RichTextContent document={document} />;
}

RichTextDocument AST shape#

The AST returned by query() for richtext fields:

typescript
type RichTextDocument = { type: 'doc'; content: RichTextNode[] };

type RichTextNode =
  | { type: 'paragraph'; children: RichTextNode[] }
  | { type: 'heading'; level: 1|2|3|4|5|6; children: RichTextNode[] }
  | { type: 'blockquote'; children: RichTextNode[] }
  | { type: 'list'; ordered: boolean; children: RichTextNode[] }
  | { type: 'listItem'; children: RichTextNode[] }
  | { type: 'text'; value: string; marks?: ('bold'|'italic'|'underline'|'code')[] }
  | { type: 'link'; url: string; children: RichTextNode[] }
  | { type: 'code'; lang?: string; value: string }
  | { type: 'thematicBreak' }
  | { type: 'break' }
  | { type: 'image'; image: ResolvedImageField }
  | { type: 'reference'; entry: unknown; display: 'inline' | 'block' }
  | { type: 'variable'; name: string }
  | { type: 'component'; name: string; props: Record<string, unknown>; children?: RichTextNode[] }
  | { type: 'condition'; field: string; branches: Record<string, RichTextDocument> };

Migrating from markdown to richtext#

  1. Change the field format in cms/octocms.config.ts from 'markdown' to 'richtext'.
  2. Run the migration script to rename companion files:
bash
npx tsx scripts/migrate-markdown-to-richtext.ts

The script finds all .md companion files for fields that are now richtext and renames them to .mdx. Plain Markdown is valid MDX, so content is preserved without transformation.

Options#

FlagDescription
--dry-runShow what would be renamed without making changes.
--collection <name>Only process a specific collection.
--field <name>Only process a specific field name.
  1. Run npm run types:gen and npm run docs:gen to update generated files.
  2. Update public pages to use RichTextContent instead of react-markdown / MarkdownContent.

Comparison with markdown#

Featuremarkdownrichtext
Storage.md companion.mdx companion
EditorBasic MDXEditor toolbarFull toolbar + slash commands + embed buttons
EmbedsNoneImages, references, conditions, variables, custom components
Query return typestring (raw markdown)RichTextDocument (AST with resolved embeds)
Public renderingreact-markdown + rehype-sanitizeRichTextContent (AST → React)
Toolbar customizationNoYes — per-field toolbar config