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

  1. Understanding the ProseMirror Node Model
  2. Setting Up the Node Schema
  3. Building the NodeView
  4. Rendering Mermaid Diagrams
  5. Adding Commands and Input Rules
  6. Handling Node Updates
  7. Full Working Example
  8. 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 mermaid library
  • 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 a content attribute, which gets serialized to data-content in 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?

  1. 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.
  2. editor.view.dispatch(...): This is how we commit changes back to the ProseMirror state. setNodeMarkup is the standard way to update a node's attributes without replacing the whole node.
  3. The update method: This is called whenever ProseMirror decides the node might need to re-render (e.g., due to collaborative editing or undo/redo). Returning true tells ProseMirror "I've handled the update myself, don't tear down and rebuild the DOM." Returning false forces ProseMirror to destroy and recreate the NodeView.
  4. 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.render is 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 id internally 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 InputRule rather than textblockTypeInputRule, since the latter is designed for textblock-type nodes with editable content. For an atomic node, a simpler approach is to use a Plugin with a regex-based text match that replaces the trigger text with insertContent.


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 type changes, ProseMirror always recreates the view.
  • If only attrs change (like our content attribute), ProseMirror calls update(node, decorations) on the existing view, giving you a chance to patch the DOM instead of a full re-render.
  • Returning false from update() 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

  1. Forgetting atom: true for non-text content. Without it, ProseMirror expects the node to have editable text content matching its content schema expression, which conflicts with rendering an SVG diagram.

  2. 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.

  3. Missing getPos in newer Tiptap versions. In Tiptap 2.x, getPos is a function you must call (getPos()), not a static value—forgetting the parentheses is a very common bug.

  4. SSR/hydration issues. If you use Tiptap with server-side rendering (e.g., Next.js), calling mermaid.render on the server will fail since it depends on browser DOM APIs. Guard NodeView rendering logic to only run client-side.

  5. Copy-paste losing your diagram. Make sure your parseHTML/renderHTML (or parseDOM in raw ProseMirror) correctly round-trips the content attribute, 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:

  1. Define a schema with the attributes you need.
  2. Use a NodeView to bridge the abstract node to real DOM/rendering logic.
  3. Provide commands for programmatic insertion.
  4. 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! 🎨