Skip to main content

TipTap Rich Text Block

The TipTapRichTextBlock is a rich text block based on TipTap (a headless editor built on top of ProseMirror). It is the successor to the Draft.js-based RichTextBlock and is created with the createTipTapRichTextBlock factory.

It offers several advantages over the old block:

  • Built on a maintained library. TipTap is actively developed, while Draft.js is no longer maintained.
  • Schema-based validation. TipTap defines the allowed content as a schema, which enables full server-side validation of the content in the API — the old block stored its content unvalidated.
  • Easier to extend. New features can be added as TipTap extensions, making it simpler to implement new functionality.
  • Standardized, portable content format. Content is stored as a nested ProseMirror JSON tree that mirrors the document structure, which is far easier to work with than Draft.js's flat list of blocks with separate offset-based ranges and a detached entityMap. The well-structured format is straightforward to render, transform, and reason about — and paves the way for AI-assisted workflows such as generating, editing, or analyzing rich text content.
Experimental

The TipTap Rich Text Block is experimental. The factory (createTipTapRichTextBlock) and the site render helper (renderTipTapRichText) are marked @experimental, and their APIs may change in a minor release without following semantic versioning.

Use it for new projects and evaluate it against your requirements, but be prepared to adapt your configuration when upgrading.

Roadmap​

The TipTap Rich Text Block will replace the Draft.js-based RichTextBlock over the following steps:

  1. Experimental (current). The block is available and functional, but its public API is not yet stable and may still change.
  2. Stable. Once the API has settled, the @experimental markers are removed and the block follows the regular semantic-versioning guarantees. This is the point at which we recommend it for all projects.
  3. Removal of the old block. In a future (major) Dextinity version, the Draft.js-based RichTextBlock (createRichTextBlock) will be deprecated and eventually removed.

We recommend planning the switch to the TipTap Rich Text Block early. New content should use the new block, and existing content can be migrated with the built-in Draft.js migration.

note

The TipTap Rich Text Block only replaces the Draft.js-based RichTextBlock. It does not replace @dextinity/admin-rte, which remains available for using the rich text editor directly (non-block usage), for instance as a standalone form field.

Replacing the RichTextBlock​

Feature comparison​

Most features of the Draft.js RichTextBlock have a direct equivalent in the TipTap Rich Text Block.

Draft.js RichTextBlockTipTap Rich Text Block
createRichTextBlockcreateTipTapRichTextBlock
rte.supports (SupportedThings[])one option per feature (Features)
bold, italic, strikethrough, sub, supbold, italic, strike, sub, sup
header-one … header-sixtextBlocks + the text block type select (Heading 1–6)
rte.standardBlockTypedefaultTextBlock
ordered-list, unordered-listorderedList, unorderedList
historyundoRedoButtons (Admin only)
link, links-removelink (pass the link block)
non-breaking-space, soft-hyphennonBreakingSpace, softHyphen
rte.blocktypeMap (custom block types)textBlockStyles + the styling select
rte.customInlineStylesinlineStyles + the inline style select
rte.listLevelMaxlistLevelMax
rte.maxBlocksmaxTextBlocks
Site rendering with redraft + RenderersSite rendering with renderTipTapRichText + nodeMapping/markMapping

Setup​

Like the old block, the TipTap Rich Text Block is configured on the API and in the Admin, and rendered on the site. The factory is available from @dextinity/cms-api and @dextinity/cms-admin under the same name.

Admin​

TipTapRichTextBlock.tsx
import { createTipTapRichTextBlock } from "@dextinity/cms-admin";

import { LinkBlock } from "./LinkBlock";

export const TipTapRichTextBlock = createTipTapRichTextBlock({ link: LinkBlock });

In the Admin, the style and placeholder options additionally carry rendering information (label and element) that is used to preview them in the editor — see Text block type and styling selects.

API​

Unlike the old block, the API now validates the entire block content against the schema derived from your configuration. As a result, the API factory takes more configuration options than the old block did. These options are named exactly like their Admin counterparts, and it is important to keep the two configurations in sync: the API schema must allow everything the Admin editor can produce, otherwise content that looks valid in the editor is rejected when saved.

tip-tap-rich-text.block.ts
import { createTipTapRichTextBlock } from "@dextinity/cms-api";

import { LinkBlock } from "./link.block";

export const TipTapRichTextBlock = createTipTapRichTextBlock({ link: LinkBlock });

The factory accepts additional Block Options such as the block name, like the old one:

tip-tap-rich-text.block.ts
export const TipTapRichTextBlock = createTipTapRichTextBlock(
{ link: LinkBlock },
{ name: "TipTapRichText" },
);

Site​

There is no site factory. Instead, @dextinity/site-react / @dextinity/site-nextjs provide the renderTipTapRichText render helper (and a hasTipTapRichTextContent helper for preview skeletons). You write your own block component that maps TipTap nodes and marks to your components:

TipTapRichTextBlock.tsx
"use client";
import {
hasTipTapRichTextContent,
PreviewSkeleton,
type PropsWithData,
renderTipTapRichText,
type TipTapMarkHandler,
type TipTapNodeHandler,
} from "@dextinity/site-nextjs";
import { LinkBlockData, TipTapRichTextBlockData } from "@src/blocks.generated";

import { LinkBlock } from "./LinkBlock";

const nodeMapping: Record<string, TipTapNodeHandler> = {
// Render each text block by its name, and handle the node types the defaults don't cover.
textBlock: ({ node, children }) =>
node.attrs?.textBlock === "heading-1" ? <h1>{children}</h1> : <p>{children}</p>,
};

const markMapping: Record<string, TipTapMarkHandler> = {
link: ({ mark, children }) => {
const linkData = mark.attrs?.data as LinkBlockData | undefined;
return linkData ? <LinkBlock data={linkData}>{children}</LinkBlock> : <>{children}</>;
},
};

export function TipTapRichTextBlock({ data }: PropsWithData<TipTapRichTextBlockData>) {
const content = data.tipTapContent;
return (
<PreviewSkeleton
title="RichText"
type="rows"
hasContent={hasTipTapRichTextContent(content)}
>
{renderTipTapRichText({ content, nodeMapping, markMapping })}
</PreviewSkeleton>
);
}

renderTipTapRichText ships default mappings for the standard nodes and marks (bulletList → <ul>, orderedList → <ol>, listItem → <li>, bold → <strong>, italic → <em>, strike → <s>, superscript → <sup>, subscript → <sub>, and the special-character nodes). A textBlock renders by its name: heading-1 to heading-6 as <h1> to <h6>, anything else as <p>. Those are the text blocks configured by default — which tag any other one renders as follows from the block's configuration, which the site doesn't have, so a block that renames a text block or adds one needs its own textBlock handler. Your nodeMapping/markMapping are merged over the defaults; supply handlers for anything the defaults don't cover (link, inlineStyle, cmsBlock/cmsInlineBlock, placeholder).

note

TipTapNode is structurally compatible with TipTap's own JSONContent type. A JSONContent value can be rendered with renderTipTapRichText, and the block data can be passed to TipTap utilities such as generateHTML() — no cast needed in either direction.

Features​

Every editor feature has its own option in the root options object, similar to TipTap's StarterKit. Most features are enabled by default and are turned off by passing false:

OptionDefaultAvailable in
boldtrueAPI + Admin
italictrueAPI + Admin
underlinefalseAPI + Admin
striketrueAPI + Admin
subtrueAPI + Admin
suptrueAPI + Admin
orderedListtrueAPI + Admin
unorderedListtrueAPI + Admin
nonBreakingSpacetrueAPI + Admin
softHyphentrueAPI + Admin
undoRedoButtonstrueAdmin only

The text block types the content may consist of are configured separately, through textBlocks.

Links are the exception to the boolean options: link takes the link block that is used for links, and passing it enables the feature. Links are disabled by default.

tip-tap-rich-text.block.ts
export const TipTapRichTextBlock = createTipTapRichTextBlock({
// Turn a feature off
strike: false,
// Turn a feature on that is disabled by default
underline: true,
// Enable links by passing the link block
link: LinkBlock,
});

The limits on the document as a whole aren't tied to a single feature and stay at the root of the options object: maxTextBlocks limits the number of top-level text blocks and listLevelMax the nesting depth of lists.

Migrating existing content​

Existing content stored by the Draft.js RichTextBlock ({ draftContent: { blocks, entityMap } }) can be migrated in place. Enable the built-in migration with the migrateFromDraftJs option on the API factory:

tip-tap-rich-text.block.ts
export const TipTapRichTextBlock = createTipTapRichTextBlock({
link: LinkBlock,
migrateFromDraftJs: true,
});

To perform an in-place replacement, register the TipTap block wherever the old RichTextBlock was used (e.g. under the same key in a BlocksBlock's supportedBlocks). Existing block instances are then live-migrated from draftContent to tipTapContent the next time they are loaded from the database.

The migration converts:

  • inline styles BOLD, ITALIC, STRIKETHROUGH, SUP, SUB → the corresponding TipTap marks,
  • header-one … header-six → the text blocks with the matching heading tag,
  • ordered/unordered list items → TipTap lists,
  • LINK entities → TipTap link marks,
  •   / ­ → non-breaking-space / soft-hyphen nodes.

It uses the block's enabled features and its textBlockStyles and maxTextBlocks options to build the target schema and validates the result. The conversion is best effort: if validation fails, it falls back to a stripped-down plain-text document in production (logging a warning) and throws in development so you can catch problems early.

Test with production content

Because the API never validated the old block's content and the TipTap block performs a complete re-validation, the migration must be tested very carefully against real production content before going live. Content that was accepted by the old block may now fail validation and be stripped down. Run the migration on a copy of your production data and review the result before deploying.

Mapping custom Draft.js styles​

If the old block used custom block types (via blocktypeMap) or custom inline styles (via customInlineStyles), map them to their TipTap equivalents by passing an object instead of true:

tip-tap-rich-text.block.ts
export const TipTapRichTextBlock = createTipTapRichTextBlock({
link: LinkBlock,
textBlockStyles: [{ name: "paragraph200", appliesTo: ["paragraph"] }],
inlineStyles: [{ name: "highlight" }],
migrateFromDraftJs: {
// Map the Draft.js `blocktypeMap` entry to a text block and its `textBlockStyle`.
textBlockMap: {
"paragraph-small": { textBlock: "paragraph", textBlockStyle: "paragraph200" },
},
// Map a Draft.js `customInlineStyles` name to a TipTap `inlineStyle`.
inlineStyleMap: { HIGHLIGHT: "highlight" },
},
});

Every mapping names the text block the Draft.js block becomes, which matters where the block type doesn't carry that information — a custom block type such as headline450 that was rendered as <h2> would otherwise become a paragraph and lose its semantic tag:

tip-tap-rich-text.block.ts
export const TipTapRichTextBlock = createTipTapRichTextBlock({
textBlockStyles: [{ name: "headline450", appliesTo: ["heading-2"] }],
migrateFromDraftJs: {
textBlockMap: {
headline450: { textBlock: "heading-2", textBlockStyle: "headline450" },
},
},
});

textBlock accepts the name of any configured text block, and an unknown name throws. textBlockStyle is optional — leave it out for a text block that offers no styles. A Draft.js block type that isn't mapped becomes the text block its own type implies: header-one–header-six keep their heading level, everything else becomes a paragraph.

New features​

Beyond replacing the old block, the TipTap Rich Text Block adds capabilities the Draft.js block did not have.

Text block type and styling selects​

The editor toolbar offers two dropdowns to structure and style text blocks:

  1. Text block type — which of the configured text blocks the current block is. Shown when more than one is configured.
  2. Styling — a named style applied to the current text block (textBlockStyles). This decouples semantics ("this is a paragraph") from appearance ("small paragraph"), so editors pick a type and a style independently.

There is a matching inline style dropdown for inlineStyles, which applies named styles to a text selection.

Styles are configured on the API (name + optional appliesTo) and in the Admin (additionally label for the dropdown and element for the editor preview). The appliesTo option limits a style to certain text block types.

tip-tap-rich-text.block.ts (API)
export const TipTapRichTextBlock = createTipTapRichTextBlock({
link: LinkBlock,
textBlockStyles: [
{ name: "paragraph300", appliesTo: ["paragraph"] },
{ name: "paragraph200", appliesTo: ["paragraph"] },
{ name: "list200", appliesTo: ["ordered-list", "unordered-list"] },
],
inlineStyles: [{ name: "highlight" }, { name: "tag", appliesTo: ["paragraph"] }],
});
TipTapRichTextBlock.tsx (Admin)
import type { HTMLAttributes } from "react";
import { FormattedMessage } from "react-intl";

export const TipTapRichTextBlock = createTipTapRichTextBlock({
link: LinkBlock,
textBlockStyles: [
{
name: "paragraph300",
label: (
<FormattedMessage
id="tipTapRichTextBlock.paragraph300"
defaultMessage="Paragraph"
/>
),
appliesTo: ["paragraph"],
element: (props: HTMLAttributes<HTMLElement>) => (
<p style={{ fontSize: 18, lineHeight: "26px" }} {...props} />
),
},
{
name: "paragraph200",
label: (
<FormattedMessage
id="tipTapRichTextBlock.paragraph200"
defaultMessage="Paragraph Small"
/>
),
appliesTo: ["paragraph"],
element: (props: HTMLAttributes<HTMLElement>) => (
<p style={{ fontSize: 15, lineHeight: "22px" }} {...props} />
),
},
],
inlineStyles: [
{
name: "highlight",
label: (
<FormattedMessage id="tipTapRichTextBlock.highlight" defaultMessage="Highlight" />
),
element: (props: HTMLAttributes<HTMLElement>) => (
<span style={{ backgroundColor: "#fff3cd" }} {...props} />
),
},
],
});

On the site, the chosen style is available as the textBlockStyle attribute (block styles) or the inlineStyle mark type (inline styles), which you resolve to your own components in nodeMapping/markMapping:

TipTapRichTextBlock.tsx (Site)
const nodeMapping: Record<string, TipTapNodeHandler> = {
paragraph: ({ node, children }) => (
<Typography variant={(node.attrs?.textBlockStyle as string | null) ?? undefined}>
{children}
</Typography>
),
};

Text blocks​

textBlocks configures the text block types the content may consist of, in the order the editor's text block type select offers them. Each entry has a name that identifies it, a tag it is stored and rendered as (p, or h1–h6), and — in the Admin — a label for the select. It defaults to a paragraph plus a heading for every level.

tip-tap-rich-text.block.ts (API)
export const TipTapRichTextBlock = createTipTapRichTextBlock({
textBlocks: [
{ name: "paragraph", tag: "p" },
{ name: "display", tag: "h1" },
{ name: "heading-1", tag: "h1" },
{ name: "heading-2", tag: "h2" },
],
defaultTextBlock: "paragraph",
});
TipTapRichTextBlock.tsx (Admin)
export const TipTapRichTextBlock = createTipTapRichTextBlock({
textBlocks: [
{
name: "paragraph",
tag: "p",
label: <FormattedMessage id="tipTapRichText.paragraph" defaultMessage="Paragraph" />,
},
{
name: "display",
tag: "h1",
label: <FormattedMessage id="tipTapRichText.display" defaultMessage="Display" />,
},
{
name: "heading-1",
tag: "h1",
label: <FormattedMessage id="tipTapRichText.heading1" defaultMessage="Heading 1" />,
},
{
name: "heading-2",
tag: "h2",
label: <FormattedMessage id="tipTapRichText.heading2" defaultMessage="Heading 2" />,
},
],
defaultTextBlock: "paragraph",
});

Every paragraph and heading is stored as one textBlock node that names its text block — the tag lives in the configuration, not in the content:

{
"type": "textBlock",
"attrs": { "textBlock": "heading-2" },
"content": [{ "type": "text", "text": "Headline" }]
}

Several text blocks may therefore share a tag, as display and heading-1 do above: both render as an h1, and the name tells them apart. That covers a design where a display headline sits above a regular heading 1 without being a style of it. On the site, one handler renders every text block by its name:

TipTapRichTextBlock.tsx (Site)
const nodeMapping: Record<string, TipTapNodeHandler> = {
textBlock: ({ node, children }) => (
<Headline variant={node.attrs?.textBlock}>{children}</Headline>
),
};

Because the name is all the content carries, renaming or removing a text block leaves the content it belongs to without a tag to render as: the API rejects it, and it needs a migration. Changing a text block's tag, on the other hand, takes effect without one.

Content stored before the textBlock node existed holds a paragraph or heading node instead. A vendor migration converts it when the block is loaded, resolving the text block from the node's tag. Since the vendor migrations run before the block's own, a project migration always sees the textBlock node — one written against { type: "heading", attrs: { level } } has to be changed to match { type: "textBlock", attrs: { textBlock } }.

defaultTextBlock names the text block new content starts with, so the select's order can be chosen independently of it. It defaults to the first entry.

The textBlocks and defaultTextBlock options must match between the API and the Admin, otherwise the API rejects content the editor produces.

Heading-only blocks​

Leaving the p text block out results in a block that only holds headings — the TipTap equivalent of the Draft.js pattern of a RichTextBlock restricted to header-* block types with a standardBlockType, for instance the headline part of a heading block:

tip-tap-headline.block.ts (API)
export const TipTapHeadlineBlock = createTipTapRichTextBlock(
{
textBlocks: [
{ name: "heading-2", tag: "h2" },
{ name: "heading-3", tag: "h3" },
{ name: "heading-4", tag: "h4" },
],
defaultTextBlock: "heading-3",
maxTextBlocks: 1,
},
{ name: "TipTapHeadline" },
);

The editor starts with an H3, the text block type select only offers the configured headings, and Mod-Alt-2/3/4 switch between them (levels no text block uses have no shortcut).

Lists are disabled without a p text block, because a list item's content starts with a paragraph. Enabling one explicitly (orderedList: true) throws, as does an empty textBlocks array — that would leave no text block type at all.

Existing content

The API rejects content the configuration doesn't allow: a text block that isn't configured, whether it was removed, renamed, or never existed. Narrowing textBlocks for a block that already holds content therefore requires a migration.

Content written before the text blocks became one node — paragraphs and headings with a level — does not fit the current schema either. How it is converted is still being decided; until then this block only reads content the current editor wrote.

Child blocks​

The TipTap Rich Text Block can embed other blocks directly into the rich text via the childBlocks option. This lets editors insert, for example, a product teaser between paragraphs, or an inline product price within a sentence — something the Draft.js block could not do.

Child blocks are keyed by a stable key. The key (not the block's name) is stored in the content, so blocks can be renamed or swapped without invalidating existing content. Each entry specifies a display:

  • "block" — a standalone block element on its own line,
  • "inline" — inline within the surrounding text.

The same configuration is used on the API and in the Admin:

tip-tap-rich-text.block.ts (API)
import { createTipTapRichTextBlock } from "@dextinity/cms-api";

export const TipTapRichTextBlock = createTipTapRichTextBlock({
link: LinkBlock,
childBlocks: {
productPrice: { block: ProductPriceBlock, display: "inline" },
productTeaser: { block: ProductTeaserBlock, display: "block" },
},
});
TipTapRichTextBlock.tsx (Admin)
import { createTipTapRichTextBlock } from "@dextinity/cms-admin";

export const TipTapRichTextBlock = createTipTapRichTextBlock({
link: LinkBlock,
childBlocks: {
productPrice: { block: ProductPriceBlock, display: "inline" },
productTeaser: { block: ProductTeaserBlock, display: "block" },
},
});

Child-block data is validated against each block's input on the API (just like top-level blocks) and exposed as nested blocks, so block loaders and transformers recurse into them. On the site, render them via nodeMapping by inspecting the node's blockType attribute:

TipTapRichTextBlock.tsx (Site)
const renderCmsBlock: TipTapNodeHandler = ({ node }) => {
if (node.attrs?.blockType === "productPrice") {
return <ProductPriceBlock data={node.attrs?.data as ProductPriceBlockData} />;
}
if (node.attrs?.blockType === "productTeaser") {
return <ProductTeaserBlock data={node.attrs?.data as ProductTeaserBlockData} />;
}
return null;
};

const nodeMapping: Record<string, TipTapNodeHandler> = {
cmsBlock: renderCmsBlock, // block-level child blocks
cmsInlineBlock: renderCmsBlock, // inline child blocks
};