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.
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 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.
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.
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.
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.
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} />;
};
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.
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>
);
};
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.
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.
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.
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.
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>
);
};
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>',
});
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('');
}
useEditor hook returns null on the server and initializes on the client, which can cause hydration errors if EditorContent renders different markup. Always guard with if (!editor) return null and consider wrapping in a client-only boundary (e.g., next/dynamic with ssr: false).CodeBlock and a custom extension using Mod-Shift-Enter), the first one wins. Place more specific extensions after generic ones.DecorationSet carefully, prefer Decoration.inline over Decoration.widget when possible, and debounce expensive computations in onUpdate handlers.setContent on every parent re-render causes cursor position loss and poor performance. Instead, treat the editor as uncontrolled: set initial content once and rely on onUpdate to sync outward. If you must update content externally, use editor.commands.setContent() only when the source of truth has genuinely changed (e.g., a remote sync event).useEditor hook handles cleanup automatically on unmount, but if you create editor instances manually via new Editor(), you must call editor.destroy() in a cleanup function to prevent memory leaks from orphaned ProseMirror views and event listeners.onUpdate, onSelectionUpdate, and other callbacks are set at editor creation time. If they reference React state via closures, the values will be stale. Use useRef to hold mutable references to the latest callback, or leverage Tiptap’s editor.on() and editor.off() methods within a useEffect that re-subscribes when dependencies change.