Rich Text Editing with Tiptap

Frontend Engineering intermediate Building with AI ~20 min read

Overview

Tiptap is a headless, framework-agnostic rich text editor built on top of ProseMirror. Unlike traditional WYSIWYG editors that ship with opinionated UIs and styling, Tiptap provides only the editing logic and document model, leaving the rendering and toolbar design entirely to the developer. This separation of concerns makes it an ideal choice for applications that need deeply customized content editing experiences without fighting against a pre-built UI.

At its core, Tiptap wraps ProseMirror’s powerful document model and transaction system with a developer-friendly API. ProseMirror treats documents as structured data conforming to a schema, where every node and mark has defined semantics. Tiptap abstracts this complexity behind an extension-based architecture, allowing developers to compose editor features like building blocks rather than configuring a monolithic editor instance.

The headless approach matters because rich text editing requirements vary dramatically across applications. A blog CMS needs different formatting controls than an internal knowledge base, and both differ from inline comment editing. By decoupling the editing engine from the presentation layer, Tiptap enables teams to build precisely the editor their product demands while leveraging battle-tested editing primitives for selection handling, input rules, paste behavior, and undo/redo.

Core Concepts

ProseMirror Schema

Every Tiptap editor operates against a schema that defines the document structure. The schema specifies which node types (paragraphs, headings, code blocks, images) and mark types (bold, italic, links) are valid, how they can be nested, and what attributes they carry. Tiptap extensions register their nodes and marks into the schema automatically, so the schema is composed implicitly from the extensions you enable rather than defined as a single monolithic object.

Extensions Architecture

Extensions are the fundamental unit of composition in Tiptap. Every feature, from basic text formatting to complex embedded content, is implemented as an extension. There are three types: Node extensions define block or inline content types, Mark extensions define inline formatting, and Extension types add behavior without introducing new content types (e.g., history, keyboard shortcuts, collaboration). Extensions can declare dependencies, define input rules, paste rules, keyboard shortcuts, and ProseMirror plugins.

Editor State & Transactions

Tiptap inherits ProseMirror’s immutable state model. The editor state contains the current document, selection, and stored marks. Every change to the document is expressed as a transaction, which is a sequence of steps (insert, delete, replace, add mark, etc.) that transforms one state into the next. This transactional model enables reliable undo/redo, makes collaborative editing possible, and allows plugins to inspect and modify changes before they are applied.

Headless / Renderless Design

Tiptap provides no default UI. The EditorContent component renders the editable area, but toolbars, menus, bubble menus, and floating panels are all built by the developer using Tiptap’s API to query editor state and dispatch commands. This means every button, dropdown, and contextual menu is a standard React component that calls editor.chain().focus().toggleBold().run() or similar command chains. The editor exposes reactive state that components can subscribe to for active-state indicators.

Collaborative Editing with Yjs

Tiptap supports real-time collaborative editing through the @tiptap/extension-collaboration extension, which integrates with Yjs, a CRDT-based shared data type library. Yjs represents the document as a Y.XmlFragment, enabling conflict-free merging of concurrent edits from multiple users. A companion CollaborationCursor extension renders other users’ cursors and selections. The transport layer (WebSocket, WebRTC, or a managed service) is pluggable and separate from the editing logic.

How It Works

Setting Up the Editor with useEditor

The useEditor hook from @tiptap/react is the primary entry point. It accepts a configuration object specifying extensions, initial content, and lifecycle callbacks. The hook returns an editor instance (or null during SSR) that provides the full API for querying state and dispatching commands.

import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Link from '@tiptap/extension-link';

const MyEditor: React.FC = () => {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        heading: { levels: [1, 2, 3] },
        codeBlock: false, // disable if using a custom code block
      }),
      Placeholder.configure({
        placeholder: 'Start writing...',
      }),
      Link.configure({
        openOnClick: false,
        autolink: true,
        HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
      }),
    ],
    content: '<p>Hello World</p>',
    editorProps: {
      attributes: {
        class: 'prose prose-sm focus:outline-none min-h-[200px] px-4 py-3',
      },
    },
    onUpdate: ({ editor }) => {
      // Emit HTML or JSON on every change
      const html = editor.getHTML();
      const json = editor.getJSON();
      console.log('Content updated:', { html, json });
    },
  });

  return <EditorContent editor={editor} />;
};

Configuring Extensions

The StarterKit bundles common extensions (paragraph, heading, bold, italic, strike, code, blockquote, bullet list, ordered list, hard break, horizontal rule, history). Each bundled extension can be configured or disabled individually through StarterKit.configure(). Additional extensions are added to the array independently. The order of extensions in the array generally does not affect behavior, but input rules and keyboard shortcuts are resolved in order, so more specific rules should appear later.

Building a Toolbar

Toolbars are standard React components that interact with the editor through the command chain API. The editor.isActive() method checks whether a formatting mark or node type is active at the current selection, enabling toggle-style button states. Commands are chained for atomicity: the chain is built, then executed with .run().

interface ToolbarProps {
  editor: Editor | null;
}

const Toolbar: React.FC<ToolbarProps> = ({ editor }) => {
  if (!editor) return null;

  return (
    <div className="toolbar flex gap-1 border-b border-gray-700 p-2">
      <button
        onClick={() => editor.chain().focus().toggleBold().run()}
        className={editor.isActive('bold') ? 'is-active' : ''}
        type="button"
      >
        Bold
      </button>
      <button
        onClick={() => editor.chain().focus().toggleItalic().run()}
        className={editor.isActive('italic') ? 'is-active' : ''}
        type="button"
      >
        Italic
      </button>
      <button
        onClick={() =>
          editor.chain().focus().toggleHeading({ level: 2 }).run()
        }
        className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        type="button"
      >
        H2
      </button>
      <button
        onClick={() => editor.chain().focus().toggleBulletList().run()}
        className={editor.isActive('bulletList') ? 'is-active' : ''}
        type="button"
      >
        Bullet List
      </button>
      <button
        onClick={() => editor.chain().focus().toggleCodeBlock().run()}
        className={editor.isActive('codeBlock') ? 'is-active' : ''}
        type="button"
      >
        Code
      </button>
      <button
        onClick={() => editor.chain().focus().undo().run()}
        disabled={!editor.can().undo()}
        type="button"
      >
        Undo
      </button>
      <button
        onClick={() => editor.chain().focus().redo().run()}
        disabled={!editor.can().redo()}
        type="button"
      >
        Redo
      </button>
    </div>
  );
};

Rendering and Content Serialization

The EditorContent component renders the ProseMirror view into the DOM. Tiptap supports multiple serialization formats: editor.getHTML() returns an HTML string, editor.getJSON() returns the ProseMirror document as a JSON tree, and editor.getText() returns plain text. Content can be set programmatically via editor.commands.setContent() accepting either HTML or JSON. For markdown, a separate serialization step using a library like turndown or prosemirror-markdown is required.

Practical Applications

Content Editing in the Component Library

In an internal component library, Tiptap powers rich text fields within content management components. Editors are configured with a curated subset of extensions matching the content model, preventing authors from introducing formatting that the rendering pipeline cannot handle. The editor output is serialized to both HTML (for preview rendering) and JSON (for storage), enabling flexible re-rendering across different contexts without reparsing HTML strings.

Structured Content in the Merchant Dashboard

The merchant dashboard uses Tiptap for structured content editing where documents follow strict schemas. Custom node extensions enforce content rules at the editor level, such as requiring that certain block types appear in a specific order or limiting nesting depth. This approach shifts validation from form submission to the editing experience itself, giving authors immediate feedback when content violates structural constraints.

Markdown Export

Both projects leverage Tiptap’s JSON output as an intermediate representation for markdown generation. Rather than converting HTML to markdown (which is lossy and fragile), custom serializers walk the ProseMirror JSON document tree and emit markdown tokens directly. This produces cleaner output and supports custom node types that have no HTML equivalent but do have natural markdown representations.

Implementation Patterns

Pattern 1: Full Editor with Toolbar and Markdown Export

This pattern demonstrates a complete editor component with a toolbar, content change handling, and markdown serialization using the turndown library.

import { useEditor, EditorContent, Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import TurndownService from 'turndown';

const turndown = new TurndownService({
  headingStyle: 'atx',
  codeBlockStyle: 'fenced',
});

interface RichTextEditorProps {
  initialContent?: string;
  onChange?: (data: { html: string; markdown: string; json: object }) => void;
  placeholder?: string;
}

export const RichTextEditor: React.FC<RichTextEditorProps> = ({
  initialContent = '',
  onChange,
  placeholder = 'Write something...',
}) => {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        heading: { levels: [1, 2, 3] },
      }),
      Link.configure({
        openOnClick: false,
        autolink: true,
        HTMLAttributes: {
          rel: 'noopener noreferrer',
          target: '_blank',
        },
      }),
      Placeholder.configure({ placeholder }),
    ],
    content: initialContent,
    onUpdate: ({ editor }) => {
      const html = editor.getHTML();
      onChange?.({
        html,
        markdown: turndown.turndown(html),
        json: editor.getJSON(),
      });
    },
  });

  return (
    <div className="rich-text-editor border border-gray-700 rounded-lg overflow-hidden">
      <Toolbar editor={editor} />
      <EditorContent
        editor={editor}
        className="prose prose-invert max-w-none p-4 min-h-[300px]"
      />
    </div>
  );
};

Pattern 2: Custom Extension for Callout Blocks

This pattern shows how to create a custom node extension that adds a callout block type with configurable variants (info, warning, danger). It demonstrates defining a schema node, parsing rules, rendering, and a keyboard shortcut.

import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react';

// 1. Define the custom node extension
const Callout = Node.create({
  name: 'callout',
  group: 'block',
  content: 'block+',

  addAttributes() {
    return {
      variant: {
        default: 'info',
        parseHTML: (element) => element.getAttribute('data-variant') || 'info',
        renderHTML: (attributes) => ({ 'data-variant': attributes.variant }),
      },
    };
  },

  parseHTML() {
    return [{ tag: 'div[data-type="callout"]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      'div',
      mergeAttributes(HTMLAttributes, { 'data-type': 'callout' }),
      0, // content hole
    ];
  },

  addNodeView() {
    return ReactNodeViewRenderer(CalloutView);
  },

  addCommands() {
    return {
      setCallout:
        (attrs) =>
        ({ commands }) =>
          commands.wrapIn(this.name, attrs),
      toggleCallout:
        (attrs) =>
        ({ commands }) =>
          commands.toggleWrap(this.name, attrs),
    };
  },

  addKeyboardShortcuts() {
    return {
      'Mod-Shift-c': () => this.editor.commands.toggleCallout({ variant: 'info' }),
    };
  },
});

// 2. React component for the node view
interface CalloutViewProps {
  node: { attrs: { variant: string } };
  updateAttributes: (attrs: Record<string, string>) => void;
}

const CalloutView: React.FC<CalloutViewProps> = ({ node, updateAttributes }) => {
  const variants = ['info', 'warning', 'danger'] as const;

  return (
    <NodeViewWrapper className={`callout callout-${node.attrs.variant}`}>
      <div className="callout-controls" contentEditable={false}>
        {variants.map((v) => (
          <button
            key={v}
            onClick={() => updateAttributes({ variant: v })}
            className={node.attrs.variant === v ? 'active' : ''}
            type="button"
          >
            {v}
          </button>
        ))}
      </div>
      <NodeViewContent className="callout-content" />
    </NodeViewWrapper>
  );
};

// 3. Usage in the editor
const editor = useEditor({
  extensions: [
    StarterKit,
    Callout,
  ],
  content: '<p>Try pressing Cmd+Shift+C to insert a callout</p>',
});

Pattern 3: Markdown Serialization from JSON Document

Rather than converting HTML to markdown, this pattern walks the ProseMirror JSON tree directly for cleaner output.

import type { JSONContent } from '@tiptap/core';

function serializeNode(node: JSONContent, depth = 0): string {
  switch (node.type) {
    case 'doc':
      return (node.content ?? []).map((n) => serializeNode(n, depth)).join('\n\n');

    case 'heading': {
      const level = node.attrs?.level ?? 1;
      const prefix = '#'.repeat(level);
      return `${prefix} ${serializeInline(node.content)}`;
    }

    case 'paragraph':
      return serializeInline(node.content);

    case 'bulletList':
      return (node.content ?? [])
        .map((li) => {
          const inner = (li.content ?? []).map((n) => serializeNode(n, depth + 1)).join('\n');
          return `${'  '.repeat(depth)}- ${inner}`;
        })
        .join('\n');

    case 'orderedList':
      return (node.content ?? [])
        .map((li, i) => {
          const inner = (li.content ?? []).map((n) => serializeNode(n, depth + 1)).join('\n');
          return `${'  '.repeat(depth)}${i + 1}. ${inner}`;
        })
        .join('\n');

    case 'codeBlock': {
      const lang = node.attrs?.language ?? '';
      const code = serializeInline(node.content);
      return `\`\`\`${lang}\n${code}\n\`\`\``;
    }

    case 'blockquote':
      return (node.content ?? [])
        .map((n) => `> ${serializeNode(n, depth)}`)
        .join('\n');

    default:
      return serializeInline(node.content);
  }
}

function serializeInline(content?: JSONContent[]): string {
  if (!content) return '';
  return content
    .map((node) => {
      if (node.type === 'text') {
        let text = node.text ?? '';
        for (const mark of node.marks ?? []) {
          if (mark.type === 'bold') text = `**${text}**`;
          if (mark.type === 'italic') text = `*${text}*`;
          if (mark.type === 'code') text = `\`${text}\``;
          if (mark.type === 'link') text = `[${text}](${mark.attrs?.href ?? ''})`;
        }
        return text;
      }
      if (node.type === 'hardBreak') return '\n';
      return '';
    })
    .join('');
}

Common Pitfalls

Further Reading