Building Custom Nodes in Tiptap/ProseMirror: A Deep Dive with Mermaid Diagrams
Tiptap is a headless, framework-agnostic rich text editor built on top of ProseMirror. One of its most powerful features is the ability to create custom nodes—content types that go beyond simple paragraphs and headings. In this post, we'll build a complete Mermaid diagram node from scratch, covering the schema definition, NodeView implementation, commands, and rendering logic.
By the end, you'll understand not just how to build a Mermaid node, but the underlying concepts that let you build any custom node.
Table of Contents
- Understanding the ProseMirror Node Model
- Setting Up the Node Schema
- Building the NodeView
- Rendering Mermaid Diagrams
- Adding Commands and Input Rules
- Handling Node Updates
- Full Working Example
- Common Pitfalls
1. Understanding the ProseMirror Node Model
Before writing code, it's important to understand three core concepts:
- Schema: Defines what kinds of nodes/marks exist in the document, their attributes, and how they can be nested. This is the "grammar" of your document.
- NodeView: A bridge between ProseMirror's abstract document model and actual DOM rendering. It gives you full control over how a node is rendered and how it responds to updates.
- Commands: Functions that describe transactions—ways to modify the document (insert, delete, update attributes, etc.).
Tiptap wraps these concepts in a friendlier API via Node.create(), but under the hood, it's all ProseMirror.
For our Mermaid node, we want:
- A block-level, atomic node (users edit it as a single unit, not character-by-character)
- An attribute to store the raw Mermaid syntax (e.g.,
graph TD; A-->B;) - A NodeView that renders the diagram using the
mermaidlibrary - The ability to click to edit the raw text and see it re-render
2. Setting Up the Node Schema
Let's start with the basic schema definition using Tiptap's Node.create():
import { Node, mergeAttributes } from '@tiptap/core'
export interface MermaidOptions {
HTMLAttributes: Record<string, any>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
mermaid: {
setMermaid: (content: string) => ReturnType
}
}
}
export const Mermaid = Node.create<MermaidOptions>({
name: 'mermaid',
group: 'block',
atom: true, // treat as a single, non-editable-by-default unit
addOptions() {
return {
HTMLAttributes: {},
}
},
addAttributes() {
return {
content: {
default: 'graph TD;\n A-->B;',
parseHTML: (element) => element.getAttribute('data-content'),
renderHTML: (attributes) => {
return {
'data-content': attributes.content,
}
},
},
}
},
parseHTML() {
return [
{
tag: 'div[data-type="mermaid"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'mermaid',
}),
]
},
})
Key Points
group: 'block': This tells the schema that our node behaves like a paragraph or heading—it takes up a block-level slot in the document.atom: true: This is crucial. It tells ProseMirror to treat the entire node as a single, indivisible unit for cursor movement and selection. Users can't place a cursor inside the rendered diagram the way they would inside a paragraph.addAttributes(): We store the raw Mermaid syntax as acontentattribute, which gets serialized todata-contentin the HTML.parseHTML/renderHTML: These define the (de)serialization to/from HTML, important for copy-paste and for the initial document import.
At this point, we have a valid schema, but nothing renders as an actual diagram—we're just outputting a <div> with a data attribute. That's where NodeViews come in.
3. Building the NodeView
A NodeView gives us imperative control over the DOM for a specific node type. Tiptap exposes this through addNodeView(), which typically wraps @tiptap/vue-3's VueNodeViewRenderer, @tiptap/react's ReactNodeViewRenderer, or a plain vanilla NodeView.
Let's build this with the vanilla JS approach first, since it best exposes what's happening under the hood, and then show the React version.
Vanilla NodeView
import { Node, mergeAttributes } from '@tiptap/core'
import { NodeViewRenderer } from '@tiptap/pm/view'
import mermaid from 'mermaid'
mermaid.initialize({ startOnLoad: false })
export const Mermaid = Node.create<MermaidOptions>({
// ... schema definition from before ...
addNodeView() {
return ({ node, editor, getPos }) => {
const container = document.createElement('div')
container.classList.add('mermaid-wrapper')
const preview = document.createElement('div')
preview.classList.add('mermaid-preview')
const textarea = document.createElement('textarea')
textarea.classList.add('mermaid-source')
textarea.style.display = 'none'
textarea.value = node.attrs.content
container.append(preview, textarea)
const renderDiagram = async (source: string) => {
try {
const id = `mermaid-${Math.random().toString(36).slice(2)}`
const { svg } = await mermaid.render(id, source)
preview.innerHTML = svg
} catch (err) {
preview.innerHTML = `<pre class="mermaid-error">${String(err)}</pre>`
}
}
renderDiagram(node.attrs.content)
// Click to toggle edit mode
preview.addEventListener('dblclick', () => {
preview.style.display = 'none'
textarea.style.display = 'block'
textarea.focus()
})
textarea.addEventListener('blur', () => {
const newContent = textarea.value
if (typeof getPos === 'function') {
editor.view.dispatch(
editor.view.state.tr.setNodeMarkup(getPos(), undefined, {
...node.attrs,
content: newContent,
})
)
}
textarea.style.display = 'none'
preview.style.display = 'block'
})
return {
dom: container,
update: (updatedNode) => {
if (updatedNode.type.name !== 'mermaid') return false
if (updatedNode.attrs.content !== node.attrs.content) {
renderDiagram(updatedNode.attrs.content)
textarea.value = updatedNode.attrs.content
}
node = updatedNode
return true
},
}
}
},
})
What's happening here?
getPos: A function that returns the current position of this node in the document. We need this to dispatch transactions that update this specific node's attributes.editor.view.dispatch(...): This is how we commit changes back to the ProseMirror state.setNodeMarkupis the standard way to update a node's attributes without replacing the whole node.- The
updatemethod: This is called whenever ProseMirror decides the node might need to re-render (e.g., due to collaborative editing or undo/redo). Returningtruetells ProseMirror "I've handled the update myself, don't tear down and rebuild the DOM." Returningfalseforces ProseMirror to destroy and recreate the NodeView. - Toggling between preview and edit mode: We use a simple double-click-to-edit pattern, since directly editing SVG content isn't meaningful—we want to edit the source.
4. Rendering Mermaid Diagrams
The core rendering logic uses the mermaid npm package:
npm install mermaid
import mermaid from 'mermaid'
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose', // needed if you allow click events inside diagrams
})
async function renderMermaidToSVG(source: string): Promise<string> {
const id = `mermaid-svg-${Date.now()}`
const { svg } = await mermaid.render(id, source)
return svg
}
A few gotchas worth knowing:
mermaid.renderis async and returns a Promise, so your NodeView update logic must handle asynchronous rendering gracefully (e.g., avoid race conditions if the user types quickly).- Unique IDs matter. Mermaid uses the
idinternally to generate SVG element IDs. If you reuse IDs across multiple diagrams, you'll get rendering conflicts. - Error handling is essential. Invalid Mermaid syntax throws, and you don't want that error to crash your whole editor—catch it and display a friendly error state instead.
5. Adding Commands and Input Rules
Now let's make it easy to insert a Mermaid node. We add a command:
addCommands() {
return {
setMermaid:
(content: string) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: { content },
})
},
}
},
Usage in your editor:
editor.commands.setMermaid('graph TD;\n A-->B;\n B-->C;')
You could also add an input rule so that typing a shortcut like ```mermaidon its own line auto-inserts a diagram node:
import { textblockTypeInputRule } from '@tiptap/core'
addInputRules() {
return [
textblockTypeInputRule({
find: /^```mermaid[\s\n]$/,
type: this.type,
}),
]
},
Note: for atom nodes, you'll typically want a custom
InputRulerather thantextblockTypeInputRule, since the latter is designed for textblock-type nodes with editable content. For an atomic node, a simpler approach is to use aPluginwith a regex-based text match that replaces the trigger text withinsertContent.
6. Handling Node Updates
One subtlety with NodeViews for atomic/complex nodes is understanding when ProseMirror calls update() vs. when it destroys and recreates the view.
Key rules:
- If the node's
typechanges, ProseMirror always recreates the view. - If only
attrschange (like ourcontentattribute), ProseMirror callsupdate(node, decorations)on the existing view, giving you a chance to patch the DOM instead of a full re-render. - Returning
falsefromupdate()forces a full teardown/rebuild—useful if the update is too complex to patch incrementally, but generally more expensive.
For our Mermaid node, since re-rendering the SVG is relatively cheap, we always return true from update() and let our internal renderDiagram() function handle applying the new SVG string.
If you're using React or Vue node views, this update-diffing is handled somewhat automatically via component re-renders when props/attrs change, which simplifies things considerably.
React Version (using @tiptap/react)
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { useEffect, useRef, useState } from 'react'
import mermaid from 'mermaid'
const MermaidComponent = ({ node, updateAttributes }: any) => {
const [editing, setEditing] = useState(false)
const [svg, setSvg] = useState('')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
mermaid
.render(`mermaid-${Math.random().toString(36).slice(2)}`, node.attrs.content)
.then(({ svg }) => {
if (!cancelled) {
setSvg(svg)
setError(null)
}
})
.catch((err) => !cancelled && setError(String(err)))
return () => {
cancelled = true
}
}, [node.attrs.content])
if (editing) {
return (
<NodeViewWrapper>
<textarea
autoFocus
defaultValue={node.attrs.content}
onBlur={(e) => {
updateAttributes({ content: e.target.value })
setEditing(false)
}}
/>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper>
{error ? (
<pre className="mermaid-error">{error}</pre>
) : (
<div onDoubleClick={() => setEditing(true)} dangerouslySetInnerHTML={{ __html: svg }} />
)}
</NodeViewWrapper>
)
}
// In your Node.create({...}):
addNodeView() {
return ReactNodeViewRenderer(MermaidComponent)
},
Notice how much simpler this is: updateAttributes is provided directly by Tiptap's React integration, abstracting away the manual getPos + dispatch dance.
7. Full Working Example (Vanilla)
Putting it all together:
import { Node, mergeAttributes } from '@tiptap/core'
import mermaid from 'mermaid'
mermaid.initialize({ startOnLoad: false })
export const Mermaid = Node.create({
name: 'mermaid',
group: 'block',
atom: true,
addAttributes() {
return {
content: {
default: 'graph TD;\nA-->B;',
parseHTML: (el) => el.getAttribute('data-content'),
renderHTML: (attrs) => ({ 'data-content': attrs.content }),
},
}
},
parseHTML() {
return [{ tag: 'div[data-type="mermaid"]' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'mermaid' })]
},
addCommands() {
return {
setMermaid:
(content: string) =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs: { content } }),
}
},
addNodeView() {
return ({ node, editor, getPos }) => {
const dom = document.createElement('div')
dom.className = 'mermaid-node'
const preview = document.createElement('div')
const textarea = document.createElement('textarea')
textarea.style.display = 'none'
dom.append(preview, textarea)
const render = async (src: string) => {
try {
const { svg } = await mermaid.render(`m-${Date.now()}`, src)
preview.innerHTML = svg
} catch (e) {
preview.innerHTML = `<pre>${e}</pre>`
}
}
render(node.attrs.content)
textarea.value = node.attrs.content
preview.ondblclick = () => {
preview.style.display = 'none'
textarea.style.display = 'block'
textarea.focus()
}
textarea.onblur = () => {
if (typeof getPos === 'function') {
editor.view.dispatch(
editor.view.state.tr.setNodeMarkup(getPos(), undefined, {
content: textarea.value,
})
)
}
preview.style.display = 'block'
textarea.style.display = 'none'
}
return {
dom,
update: (updated) => {
if (updated.type.name !== 'mermaid') return false
render(updated.attrs.content)
textarea.value = updated.attrs.content
node = updated
return true
},
}
}
},
})
Register it with your editor:
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Mermaid } from './mermaid-extension'
const editor = new Editor({
extensions: [StarterKit, Mermaid],
content: '<p>Hello world</p>',
})
editor.commands.setMermaid('graph TD;\nA-->B;\nB-->C;')
8. Common Pitfalls
Forgetting
atom: truefor non-text content. Without it, ProseMirror expects the node to have editable text content matching itscontentschema expression, which conflicts with rendering an SVG diagram.Not handling async rendering races. If a user rapidly edits the Mermaid source, multiple
mermaid.render()calls can resolve out of order. Use a request ID or cancellation flag to ensure only the latest result is applied.Missing
getPosin newer Tiptap versions. In Tiptap 2.x,getPosis a function you must call (getPos()), not a static value—forgetting the parentheses is a very common bug.SSR/hydration issues. If you use Tiptap with server-side rendering (e.g., Next.js), calling
mermaid.renderon the server will fail since it depends on browser DOM APIs. Guard NodeView rendering logic to only run client-side.Copy-paste losing your diagram. Make sure your
parseHTML/renderHTML(orparseDOMin raw ProseMirror) correctly round-trips thecontentattribute, otherwise copy-pasting Mermaid blocks between documents will silently drop the diagram source.
Conclusion
Custom nodes are where Tiptap and ProseMirror really shine, letting you embed arbitrarily rich, interactive content—diagrams, code sandboxes, polls, embeds—directly into a structured document model. The Mermaid node we built here demonstrates the essential pattern:
- Define a schema with the attributes you need.
- Use a NodeView to bridge the abstract node to real DOM/rendering logic.
- Provide commands for programmatic insertion.
- Handle updates gracefully to keep the editor responsive.
Once you understand this pattern, you can apply it to virtually any embeddable content type—it's just a matter of swapping out the rendering library (Mermaid, KaTeX, D3, a video player, etc.) while reusing the same schema/NodeView/command scaffolding.
Happy building! 🎨