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.
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:
- Experimental (current). The block is available and functional, but its public API is not yet stable and may still change.
- Stable. Once the API has settled, the
@experimentalmarkers are removed and the block follows the regular semantic-versioning guarantees. This is the point at which we recommend it for all projects. - Removal of the old block. In a future (major) COMET 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.
The TipTap Rich Text Block only replaces the Draft.js-based RichTextBlock. It does not replace @comet/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 RichTextBlock | TipTap Rich Text Block |
|---|---|
createRichTextBlock | createTipTapRichTextBlock |
rte.supports (SupportedThings[]) | supports (TipTapSupports[]) |
bold, italic, strikethrough, sub, sup | bold, italic, strike, sub, sup |
header-one … header-six | heading support + the text-block-type select (Heading 1–6) |
ordered-list, unordered-list | ordered-list, unordered-list |
history | history (Admin only) |
link, links-remove | pass a link block (automatically enables link support) |
non-breaking-space, soft-hyphen | non-breaking-space, soft-hyphen |
rte.blocktypeMap (custom block types) | textBlockStyles + the styling select |
rte.customInlineStyles | inlineStyles + the inline style select |
rte.listLevelMax | listLevelMax |
rte.maxBlocks | maxTextBlocks |
Site rendering with redraft + Renderers | Site 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 @comet/cms-api and @comet/cms-admin under the same name.
Admin
import { createTipTapRichTextBlock } from "@comet/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.
import { createTipTapRichTextBlock } from "@comet/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:
export const TipTapRichTextBlock = createTipTapRichTextBlock(
{ link: LinkBlock },
{ name: "TipTapRichText" },
);
Site
There is no site factory. Instead, @comet/site-react / @comet/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:
"use client";
import {
hasTipTapRichTextContent,
PreviewSkeleton,
type PropsWithData,
renderTipTapRichText,
type TipTapMarkHandler,
type TipTapNode,
type TipTapNodeHandler,
} from "@comet/site-nextjs";
import { LinkBlockData, TipTapRichTextBlockData } from "@src/blocks.generated";
import { LinkBlock } from "./LinkBlock";
const nodeMapping: Record<string, TipTapNodeHandler> = {
// Override the defaults or handle additional node types, e.g. styled paragraphs/headings.
paragraph: ({ children }) => <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 as TipTapNode;
return (
<PreviewSkeleton
title="RichText"
type="rows"
hasContent={hasTipTapRichTextContent(content)}
>
{renderTipTapRichText({ content, nodeMapping, markMapping })}
</PreviewSkeleton>
);
}
renderTipTapRichText ships default mappings for the standard nodes and marks (paragraph → <p>, heading → <h1>…<h6>, bulletList → <ul>, orderedList → <ol>, listItem → <li>, bold → <strong>, italic → <em>, strike → <s>, superscript → <sup>, subscript → <sub>, and the special-character nodes). Your nodeMapping/markMapping are merged over the defaults; supply handlers for anything the defaults don't cover (link, inlineStyle, cmsBlock/cmsInlineBlock, placeholder, styled text blocks).
The generated type TipTapRichTextBlockData.tipTapContent is unknown on the site. Cast it to TipTapNode before passing it to renderTipTapRichText.
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:
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→ heading nodes,- ordered/unordered list items → TipTap lists,
LINKentities → TipTaplinkmarks,/→ non-breaking-space / soft-hyphen nodes.
It uses the block's supports, textBlockStyles, link, 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.
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:
export const TipTapRichTextBlock = createTipTapRichTextBlock({
link: LinkBlock,
textBlockStyles: [{ name: "paragraph200", appliesTo: ["paragraph"] }],
inlineStyles: [{ name: "highlight" }],
migrateFromDraftJs: {
// Map the Draft.js `blocktypeMap` entry to a TipTap `textBlockStyle`.
textBlockStyleMap: { "paragraph-small": "paragraph200" },
// Map a Draft.js `customInlineStyles` name to a TipTap `inlineStyle`.
inlineStyleMap: { HIGHLIGHT: "highlight" },
},
});
Migration versioning
migrateFromDraftJs reserves version 1 for the injected Draft.js → TipTap migration. Your own block migrations must therefore start at version 2:
import { createTipTapRichTextBlock, typeSafeBlockMigrationPipe } from "@comet/cms-api";
export const TipTapRichTextBlock = createTipTapRichTextBlock(
{
link: LinkBlock,
migrateFromDraftJs: true,
},
{
name: "TipTapRichText",
migrate: {
version: 2, // 1 is reserved for the Draft.js migration
migrations: typeSafeBlockMigrationPipe([Heading1ToHeading2Migration]),
},
},
);
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:
- Text block type — the semantic type of the current block: Default (paragraph) or Heading 1 … Heading 6. Shown when
headingis insupports. - 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.
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"] }],
});
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:
const nodeMapping: Record<string, TipTapNodeHandler> = {
paragraph: ({ node, children }) => (
<Typography variant={(node.attrs?.textBlockStyle as string | null) ?? undefined}>
{children}
</Typography>
),
};
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:
import { createTipTapRichTextBlock } from "@comet/cms-api";
export const TipTapRichTextBlock = createTipTapRichTextBlock({
link: LinkBlock,
childBlocks: {
productPrice: { block: ProductPriceBlock, display: "inline" },
productTeaser: { block: ProductTeaserBlock, display: "block" },
},
});
import { createTipTapRichTextBlock } from "@comet/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:
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
};