TipTap
The headless rich-text editor built on ProseMirror with first-class Yjs support
Use Cases
Architecture
ProseMirror is the most powerful editor framework ever built. It is also genuinely hard to use. The API is low-level, the documentation assumes familiarity with document schemas and state machines, and building a basic bold button requires understanding transactions, steps, and marks. TipTap wraps ProseMirror with a developer-friendly API while keeping the full power accessible when needed. Think of it as React to ProseMirror's DOM.
Notion, GitBook, and dozens of SaaS products use TipTap in production. The editor handles the hard parts (cursor management, selection, input handling, collaboration) so teams can focus on the product.
The ProseMirror Document Model (Concrete Example)
Take a simple paragraph:
A paragraph with bold and italic text.
Where "bold" is bold and "italic" is italic. ProseMirror represents this as a tree:
doc
paragraph
text("A paragraph with ")
text("bold", marks: [bold])
text(" and ")
text("italic", marks: [italic])
text(" text.")
This looks like a DOM tree, and that is intentional. But unlike the DOM, this tree is immutable. It is never modified directly. Every change goes through a Transaction.
Typing "x" at position 5 produces: ReplaceStep(5, 5, Slice(text("x"))). Making text bold from position 10 to 14 produces: AddMarkStep(from: 10, to: 14, mark: bold). These Steps are the atomic unit of editing. They can be inverted (for undo), serialized (for collaboration), and composed (for batching multiple changes into one operation).
The Schema defines what is structurally valid. Say the schema declares that a heading can only contain inline content. If any code tries to nest a blockquote inside a heading, ProseMirror rejects the transaction before it touches the document. It is like a type system for content. Invalid states are not merely discouraged. They are impossible.
This matters more than it sounds. Without schema enforcement, concurrent edits in a collaborative editor can produce document states that rendering code never expected. A list item containing another list item containing a horizontal rule containing a paragraph. Schema enforcement prevents that entire class of bugs.
How Collaboration Works (The Yjs Binding)
The @tiptap/extension-collaboration extension bridges two fundamentally different worlds.
ProseMirror thinks in positions. "Insert character at position 5." "Delete characters from position 10 to 14." These are relative to the current document state. If the document changes between when the position was computed and when the operation applies, the position is wrong.
Yjs thinks in unique IDs. Every character is a CRDT item with a globally unique (clientID, clock) pair. It does not matter what the document looks like when an operation is applied. The item knows where it belongs relative to its neighbors, not relative to a position number.
The y-prosemirror binding translates between them. Here is what happens on a keystroke:
- Local user types "a" at position 5 in ProseMirror.
- ProseMirror creates a
ReplaceStep(5, 5, Slice(text("a"))). - The binding intercepts this Step and translates it into a Yjs insert operation on the
Y.XmlFragmentat the CRDT position corresponding to ProseMirror position 5. - Yjs broadcasts the update to other clients via the sync provider.
When a remote update arrives, the reverse happens:
- A remote Yjs update arrives (User B typed "b" at what was position 5 on their screen).
- Yjs resolves the conflict using YATA (lower clientID goes first if both inserted at the same position).
- The binding converts the resolved Yjs state back into a ProseMirror transaction.
- ProseMirror applies the transaction. Both editors now show the same result.
The tricky part is schema conflicts. User A wraps a paragraph in a blockquote. User B simultaneously deletes that paragraph's text. After the Yjs merge, the result might be an empty blockquote, which could violate the schema if blockquotes require at least one child with content. The binding runs a sanitization pass after every remote merge that adjusts the document to satisfy schema constraints. This is why schema design matters so much in collaborative editors. An overly strict schema means more sanitization passes and more surprising corrections to user input.
Building Custom Extensions (Callout Block Example)
TipTap's extension system is how custom functionality gets added. Here is a simplified callout block extension:
const Callout = Node.create({
name: 'callout',
group: 'block',
content: 'inline*',
addAttributes() {
return {
type: { default: 'info' } // info, warning, error
}
},
parseHTML() {
return [{ tag: 'div[data-callout]' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-callout': '', ...HTMLAttributes }, 0]
},
addInputRules() {
return [
// Typing "!!! " at the start of a line creates a callout
textblockTypeInputRule({
find: /^!!!\s$/,
type: this.type,
})
]
},
})
This extension defines a block-level node that contains inline content. The input rule means users can type !!! to create a callout without touching a toolbar. In collaborative mode, Yjs syncs this node like any other. The custom attributes (type: "info") are part of the CRDT document and merge correctly across clients.
The pattern scales. Need a code block with language selection? A table with column resizing? An image with caption and alignment? Each is an extension with its own node spec, rendering logic, and input rules. TipTap's StarterKit bundles 16 common extensions (paragraph, heading, bold, italic, code, lists, blockquote, etc.) into one import.
Production Considerations
Undo/redo with collaboration. TipTap's default undo uses ProseMirror's history plugin, which maintains a single undo stack for all changes. In collaborative mode, pressing Ctrl+Z undoes the last change, regardless of who made it. That is almost never what users expect. Switch to the Yjs UndoManager via @tiptap/extension-collaboration. It tracks per-user undo stacks. Alice's Ctrl+Z only undoes Alice's changes.
Document size limits. ProseMirror starts to slow down noticeably past 100K words in a single document. The bottleneck is not rendering (the view only renders what is visible) but transaction processing. Every keystroke validates the entire document against the schema. For very long documents, split content into sections that load independently or paginate the document.
Server-side rendering. TipTap can render to HTML without a browser via generateHTML(doc, extensions). Use this for search indexing (feed the HTML to Elasticsearch), email previews (render the first 200 characters), and SEO (server-render the content for crawlers). The output is deterministic. Same document and extensions always produce the same HTML.
Testing strategy. Do not write Cypress tests that click around in the editor and check what text appears. That is fragile and slow. Instead, use ProseMirror's prosemirror-test-builder to construct document states programmatically, apply transactions, and assert on the resulting document structure. Test the model, not the pixels.
Failure Scenarios
Scenario 1: Schema mismatch after deploy. A new version ships that removes a custom node type ("callout"). Users running the old client still have documents containing callout nodes. When those documents sync to a new client, ProseMirror encounters a node type it does not recognize. The result depends on how it is handled. Without handling, the editor crashes. Registering a fallback node that renders unknown types as a generic div preserves the content but looks wrong.
Prevention: Never remove a node type from the schema in a single deploy. Instead, deprecate it first. Keep the node type registered but stop creating new instances. After all clients have upgraded (give it a release cycle), remove the node type and add a migration that converts existing callout nodes to paragraphs or blockquotes.
Scenario 2: Yjs sync divergence after long offline. A user edits a document offline for 4 hours. Meanwhile, 8 other users restructure the document heavily: move sections around, convert headings to paragraphs, delete entire pages. When the offline user reconnects, the Yjs merge produces a technically correct but practically confusing result. Their edits land in unexpected positions because the surrounding context changed completely.
Detection: Track the gap between a client's state vector and the server's. If the gap exceeds a threshold (say, 10,000 operations), flag it.
Mitigation: Show the user a diff before applying their offline changes. Or cap the offline buffer and prompt the user to manually reconcile if the gap is too large. Some teams simply discard offline edits older than a configurable window and notify the user. Losing 4 hours of work is bad, but silently merging it into the wrong place is worse.
Pros
- • Headless architecture. Zero UI opinions. Full control over every pixel of the editor
- • ProseMirror backbone provides a battle-tested document model with schema enforcement
- • First-class Yjs collaboration via @tiptap/extension-collaboration
- • Extension system for custom nodes, marks, and keyboard shortcuts
- • Schema-enforced structure prevents invalid document states at the transaction level
Cons
- • ProseMirror learning curve is steep. The mental model is not intuitive at first
- • Bundle size grows with every extension added. Tree-shaking helps but only so much
- • Documentation has gaps for advanced ProseMirror interop and custom node specs
- • Mobile support is limited. Touch selection and virtual keyboards are pain points
When to use
- • Building a rich text editor with full control over the UI
- • Need real-time collaboration with multiple simultaneous editors
- • Want schema-enforced content structure (headings can only contain inline content, etc.)
- • Building a Notion-style block editor with custom block types
When NOT to use
- • Plain text input where a textarea is sufficient
- • Static content display with no editing needed
- • WYSIWYG email composers (email HTML is a different beast entirely)
- • Teams that need a working editor in a day. The learning curve is real
Key Points
- •TipTap wraps ProseMirror the way React wraps the DOM. A developer-friendly API while keeping full access to ProseMirror internals when needed
- •Every edit is a Transaction containing Steps. Typing 'x' at position 5 creates a ReplaceStep(5, 5, Slice(text('x'))). Steps can be inverted (undo), serialized (collaboration), and composed (batching)
- •The Schema defines what is valid. If a heading can only contain inline content, ProseMirror rejects any transaction that tries to nest a blockquote inside it. This happens before the change touches the document
- •The Yjs collaboration binding translates between two worlds: ProseMirror Steps (position-based) and Yjs operations (CRDT items with unique IDs). This translation layer is where schema conflicts from concurrent edits get resolved
- •Extensions are the unit of modularity. Each extension declares a node spec, input rules, keyboard shortcuts, and paste rules. The StarterKit extension bundles 16 common extensions into one import
- •Server-side rendering via generateHTML() converts ProseMirror documents to HTML for search indexing, email previews, and SEO without running a browser
Common Mistakes
- ✗Fighting ProseMirror instead of learning it. TipTap is a wrapper, not a replacement. When something breaks, the answer is almost always in the ProseMirror docs, not the TipTap docs
- ✗Making the schema too strict for collaborative editing. If User A adds a custom block while User B has an older client without that block type, the merge fails. Keep deprecated node types in the schema for at least one release cycle
- ✗Not debouncing the onUpdate callback. It fires on every keystroke. If the handler writes to a database or runs expensive validation, performance tanks
- ✗Putting business logic inside extensions. Extensions define document structure and editor behavior. Validation rules, API calls, and data transformations belong in the application layer
- ✗Using ProseMirror's built-in history plugin with Yjs collaboration. It undoes everyone's changes, not just yours. Use the Yjs UndoManager instead, which tracks per-user undo stacks