TipTap Editor Patterns
Quick Guide: TipTap is a headless, framework-agnostic rich text editor built on ProseMirror. Everything is an extension -- nodes define block/inline content, marks define formatting, extensions add functionality. Use
useEditorhook (React/Vue) or theEditorclass directly. Prefer JSON serialization over HTML. SetimmediatelyRender: falsefor SSR. Current: v3.x -- Floating UI replaces Tippy.js, menus import from/menussub-path, StarterKit includes Link/Underline by default.
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST set immediatelyRender: false in useEditor when using SSR/SSG frameworks -- TipTap must never render on the server)
(You MUST import BubbleMenu and FloatingMenu from the /menus sub-path -- e.g. @tiptap/react/menus in v3)
(You MUST define name, group, parseHTML, and renderHTML on every custom Node -- missing any breaks schema resolution)
(You MUST use editor.chain().focus()...run() for chained commands -- forgetting .focus() loses cursor position, forgetting .run() silently does nothing)
Auto-detection: TipTap, tiptap, @tiptap/core, @tiptap/react, @tiptap/vue-3, @tiptap/starter-kit, useEditor, EditorContent, BubbleMenu, FloatingMenu, Node.create, Mark.create, Extension.create, NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer, ProseMirror, editor.chain, editor.commands, addKeyboardShortcuts, addInputRules, addNodeView
When to use:
- Building rich text editors with custom formatting and block types
- Creating WYSIWYG editors with toolbar, bubble menu, or floating menu UIs
- Implementing custom nodes (embeds, mentions, code blocks with syntax highlighting)
- Serializing editor content to JSON or HTML for persistence
- Adding keyboard shortcuts, input rules, or paste rules to an editor
When NOT to use:
- Plain text input or textarea (use native HTML elements)
- Markdown-only editors without rich text rendering (use a markdown parser)
- Read-only content display (use a static renderer or HTML)
Key patterns covered:
- Editor setup with useEditor hook and EditorContent component
- Extension architecture: Node, Mark, and Extension types
- Custom node and mark creation with schema, commands, and keyboard shortcuts
- BubbleMenu and FloatingMenu for contextual toolbars
- React node views for complex interactive blocks
- Content serialization (JSON preferred) and persistence
- Input rules and paste rules for automatic formatting
Detailed Resources:
- examples/core.md - Editor setup, extensions, serialization, toolbar
- examples/custom-extensions.md - Custom nodes, marks, input rules, keyboard shortcuts
- examples/menus.md - BubbleMenu, FloatingMenu, slash commands
- reference.md - Decision frameworks, StarterKit contents, anti-patterns
Philosophy
TipTap is a headless editor framework -- it provides behavior, schema, and state management without imposing any UI. You build the UI (toolbars, menus, formatting controls) yourself using your preferred component framework and styling solution.
Everything is an extension. Even core features like paragraphs, bold text, and undo/redo are extensions. This means:
- You control the schema -- only include what your editor needs
- Extensions are composable -- combine, configure, or extend any extension
- Custom content types are first-class -- creating a custom node is the same API as built-in nodes
ProseMirror under the hood. TipTap wraps ProseMirror, so you get its battle-tested schema system, transaction model, and plugin architecture. When TipTap's API isn't enough, drop down to ProseMirror directly via addProseMirrorPlugins().
Framework-agnostic core. @tiptap/core works with vanilla JS. Framework adapters (@tiptap/react, @tiptap/vue-3) add hooks and components but the editor logic is shared.
Core Patterns
Pattern 1: Editor Setup
The useEditor hook initializes the editor with extensions and content. EditorContent renders the editable area.
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
const INITIAL_CONTENT = "<p>Start typing...</p>";
function RichTextEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: INITIAL_CONTENT,
immediatelyRender: false, // Required for SSR frameworks
});
return <EditorContent editor={editor} />;
}
Why good: StarterKit bundles common extensions (paragraphs, headings, lists, bold, italic, etc.), immediatelyRender: false prevents SSR hydration mismatch
Key useEditor options: extensions (required), content (HTML string or JSON), editable, autofocus ("start", "end", "all", number, boolean), editorProps (ProseMirror props like attributes for CSS classes), onUpdate callback
See examples/core.md for full setup with toolbar and configuration options.
Pattern 2: Extension Types
TipTap has three extension types that map to ProseMirror's schema model:
| Type | Purpose | Examples |
|---|---|---|
| Node | Content blocks and inline elements | Paragraph, Heading, Image, CodeBlock, Table |
| Mark | Formatting applied to text ranges | Bold, Italic, Link, Highlight, Code |
| Extension | Functionality without schema changes | UndoRedo, CharacterCount, Placeholder, Focus |
import { Node } from "@tiptap/core";
import { Mark } from "@tiptap/core";
import { Extension } from "@tiptap/core";
// Each type uses the same .create() factory
const CustomNode = Node.create({ name: "customNode" /* ... */ });
const CustomMark = Mark.create({ name: "customMark" /* ... */ });
const CustomExt = Extension.create({ name: "customExt" /* ... */ });
Key distinction: Nodes and Marks define schema (parseHTML/renderHTML). Extensions add behavior only.
See examples/custom-extensions.md for complete custom node and mark examples.
Pattern 3: Commands and Chaining
Commands modify editor state. Chain multiple commands and call .run() to execute.
// Single command
editor.commands.toggleBold();
// Chained commands -- focus() keeps cursor in editor
editor.chain().focus().toggleBold().run();
// Check if a command can execute (without running it)
const canToggleBold = editor.can().toggleBold();
// Conditional formatting
editor.chain().focus().toggleHeading({ level: 2 }).run();
Why .focus() matters: Without it, clicking a toolbar button moves focus out of the editor. .focus() restores it before applying the command.
Why .run() matters: Chain builds a transaction but does not apply it until .run() is called. Forgetting .run() silently does nothing.
See examples/core.md for toolbar integration with isActive checks.
Pattern 4: Content Serialization
TipTap supports JSON and HTML output. JSON is recommended -- it preserves the document structure, is easier to parse, and allows external edits without an HTML parser.
// Get content as JSON (recommended for persistence)
const json = editor.getJSON();
// Get content as HTML
const html = editor.getHTML();
// Get plain text
const text = editor.getText({ blockSeparator: "\n\n" });
// Set content from JSON or HTML
editor.commands.setContent(jsonData);
editor.commands.setContent("<p>HTML content</p>");
Why JSON over HTML: JSON maps directly to the ProseMirror document tree. HTML requires parsing and may lose information if the schema changes. JSON also enables easier diffing, validation, and migration.
See examples/core.md for persistence patterns with localStorage and API.
Pattern 5: Custom Node Creation
Custom nodes define new content types in the editor schema. Every node needs name, group, parseHTML, and renderHTML.
import { Node, mergeAttributes } from "@tiptap/core";
const Callout = Node.create({
name: "callout",
group: "block",
content: "block+",
addAttributes() {
return {
type: { default: "info" },
};
},
parseHTML() {
return [{ tag: 'div[data-type="callout"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes({ "data-type": "callout" }, HTMLAttributes),
0,
];
},
});
Key schema properties: group ("block" or "inline"), content (ProseMirror content expression like "block+", "inline*", "text*"), inline (boolean), atom (true = non-editable unit), selectable, draggable
The 0 in renderHTML: Represents the content hole where child content renders. Omit for atom/leaf nodes.
See examples/custom-extensions.md for complete nodes with commands, keyboard shortcuts, and input rules.
Pattern 6: Custom Mark Creation
Marks apply formatting to text ranges. They need parseHTML and renderHTML like nodes but use addAttributes for styling properties.
import { Mark, mergeAttributes } from "@tiptap/core";
const Highlight = Mark.create({
name: "highlight",
addAttributes() {
return {
color: { default: "yellow" },
};
},
parseHTML() {
return [{ tag: "mark" }];
},
renderHTML({ HTMLAttributes }) {
return ["mark", mergeAttributes(HTMLAttributes), 0];
},
addCommands() {
return {
toggleHighlight:
(attrs) =>
({ commands }) => {
return commands.toggleMark(this.name, attrs);
},
};
},
});
Mark-specific options: inclusive (whether typing at mark boundary extends the mark), excludes (marks that cannot coexist -- e.g. bold excludes itself), spanning (whether mark can span multiple nodes)
See examples/custom-extensions.md for marks with keyboard shortcuts and input rules.
Pattern 7: BubbleMenu and FloatingMenu
BubbleMenu appears on text selection. FloatingMenu appears on empty lines. Both use Floating UI for positioning in v3.
import { BubbleMenu, FloatingMenu } from "@tiptap/react/menus";
// BubbleMenu -- appears when text is selected
<BubbleMenu editor={editor}>
<button => editor.chain().focus().toggleBold().run()}>Bold</button>
</BubbleMenu>
// FloatingMenu -- appears on empty lines
<FloatingMenu editor={editor}>
<button => editor.chain().focus().setHeading({ level: 1 }).run()}>H1</button>
</FloatingMenu>
Key props: editor (required), shouldShow callback for custom visibility logic, pluginKey for multiple menu instances, Floating UI middleware options (placement, offset, flip)
See examples/menus.md for shouldShow patterns, multiple menus, and slash command implementation.
Pattern 8: React Node Views
For complex interactive blocks (widgets, embeds, counters), use React components as node views.
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import { ReactNodeViewRenderer } from "@tiptap/react";
// The React component receives props from TipTap
function CalloutView({ node, updateAttributes }) {
return (
<NodeViewWrapper className="callout" data-type={node.attrs.type}>
<select
contentEditable={false}
value={node.attrs.type}
=> updateAttributes({ type: e.target.value })}
>
<option value="info">Info</option>
<option value="warning">Warning</option>
</select>
<NodeViewContent className="callout-content" />
</NodeViewWrapper>
);
}
// Register in the node extension
const CalloutNode = Node.create({
name: "callout",
// ... schema config ...
addNodeView() {
return ReactNodeViewRenderer(CalloutView);
},
});
Props available: editor, node, selected, extension, getPos(), updateAttributes(), deleteNode(), decorations
NodeViewWrapper is required -- it sets up the DOM structure TipTap expects. NodeViewContent renders editable child content. Use contentEditable={false} on non-editable parts (buttons, selects).
See examples/custom-extensions.md for complete React node view examples.
Decision Framework
Extension Type Selection
What are you adding to the editor?
|
+-> New content type (renders in document)?
| +-> Block-level (paragraph, heading, image)? -> Node with group: "block"
| +-> Inline element (mention, emoji)? -> Node with group: "inline", inline: true
| +-> Text formatting (bold, highlight, link)? -> Mark
|
+-> New behavior (no schema change)?
+-> Keyboard shortcut? -> Extension with addKeyboardShortcuts
+-> Character count, placeholder? -> Extension
+-> ProseMirror plugin? -> Extension with addProseMirrorPlugins
Node Interactivity
How interactive is the node?
|
+-> Static content (just renders HTML)?
| -> renderHTML only, no node view needed
|
+-> Needs editable child content?
| -> NodeViewContent inside NodeViewWrapper
|
+-> Complex interactive UI (buttons, inputs)?
-> ReactNodeViewRenderer with contentEditable={false} on controls
Content Serialization
How will you store editor content?
|
+-> Database / API? -> JSON (getJSON) -- structured, diffable, migratable
+-> Display as HTML elsewhere? -> HTML (getHTML) -- for rendering outside editor
+-> Search indexing? -> Plain text (getText) -- for full-text search
RED FLAGS
High Priority Issues:
- Missing
immediatelyRender: falsewith SSR frameworks -- causes hydration mismatch and server rendering errors - Importing BubbleMenu/FloatingMenu from
@tiptap/reactinstead of@tiptap/react/menusin v3 -- wrong import path - Forgetting
.run()on command chains -- builds transaction but never applies it, silently does nothing - Forgetting
.focus()before commands in toolbar buttons -- cursor leaves editor, commands may target wrong position - Missing
parseHTML/renderHTMLon custom nodes/marks -- content cannot be loaded from or exported to HTML/JSON - Using
editor.state.docdirectly to modify content instead of commands/transactions -- bypasses TipTap's update cycle
Medium Priority Issues:
- Not checking
editor.can()before rendering toolbar buttons as active/disabled -- buttons appear clickable when command would fail - Creating one monolithic extension instead of composable smaller ones -- harder to reuse and configure
- Using HTML for persistence when JSON would work -- JSON is more structured, diffable, and schema-aware
- Inline
editor.getJSON()on every keystroke without debouncing -- performance issue on large documents
Gotchas & Edge Cases:
getPos()in node views can returnundefinedin v3 -- always check before usingeditorfromuseEditorisnullon first render and during SSR -- guard alleditor.access- StarterKit v3 includes Link and Underline by default -- adding them separately causes duplicate extension errors
NodeViewContenttag cannot change at runtime -- setasprop once (e.g.as="p")mergeAttributesis required inrenderHTMLto preserve user-added attributes (class, style, data-*)addInputRulesregex must end with$(caret at cursor position);addPasteRulesregex should NOT end with$but must use/gflag- Multiple input rules matching the same pattern -- only the first match in extension order fires
- ProseMirror content expressions:
"block+"means one-or-more blocks,"inline*"means zero-or-more inline,"text*"means text only -- mismatches cause schema validation errors
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST set immediatelyRender: false in useEditor when using SSR/SSG frameworks -- TipTap must never render on the server)
(You MUST import BubbleMenu and FloatingMenu from the /menus sub-path -- e.g. @tiptap/react/menus in v3)
(You MUST define name, group, parseHTML, and renderHTML on every custom Node -- missing any breaks schema resolution)
(You MUST use editor.chain().focus()...run() for chained commands -- forgetting .focus() loses cursor position, forgetting .run() silently does nothing)
Failure to follow these rules will cause SSR crashes, import errors, silent command failures, and broken editor schemas.