Tiptap 3.0: Rich Text Editor That Actually Handles Collaborative Editing

I tried five rich text editors before landing on Tiptap 3.0. Slate.js was flexible but I spent more time writing boilerplate than features. Quill was too opinionated and impossible to customize. Draft.js is dead. ProseMirror directly is powerful but the learning curve is measured in weeks. Tiptap 3.0 wraps ProseMirror with a sane API, adds built-in collaboration via Yjs, and actually handles documents with 100K+ words without choking. Here's how I built a Notion-like editor with it.

What's New in Tiptap 3.0

Building a Notion-Like Editor

npm install @tiptap/react @tiptap/starter-kit @tiptap/pm @tiptap/extension-collaboration
// components/Editor.tsx
"use client"

import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder"
import TaskList from "@tiptap/extension-task-list"
import TaskItem from "@tiptap/extension-task-item"
import Highlight from "@tiptap/extension-highlight"

export function NotionEditor({ content, onUpdate }) {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        heading: { levels: [1, 2, 3] },
      }),
      Placeholder.configure({
        placeholder: "Type '/' for commands...",
      }),
      TaskList,
      TaskItem.configure({ nested: true }),
      Highlight.configure({ multicolor: true }),
    ],
    content,
    onUpdate: ({ editor }) => {
      onUpdate(editor.getJSON())
    },
    editorProps: {
      attributes: {
        class: "prose prose-lg max-w-none focus:outline-none min-h-[500px]",
      },
    },
  })

  if (!editor) return null

  return (
    <div className="max-w-3xl mx-auto px-4 py-8">
      {/* Floating toolbar for selected text */}
      <BubbleMenu editor={editor} className="bg-white shadow-lg rounded-lg p-2 flex gap-1">
        <button onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive("bold") ? "bg-gray-200 px-2 py-1 rounded" : "px-2 py-1 rounded"}>B</button>
        <button onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive("italic") ? "bg-gray-200 px-2 py-1 rounded" : "px-2 py-1 rounded"}>I</button>
        <button onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive("strike") ? "bg-gray-200 px-2 py-1 rounded" : "px-2 py-1 rounded"}>S</button>
        <button onClick={() => editor.chain().focus().toggleHighlight().run()}
          className={editor.isActive("highlight") ? "bg-gray-200 px-2 py-1 rounded" : "px-2 py-1 rounded"}>H</button>
      </BubbleMenu>

      <EditorContent editor={editor} />
    </div>
  )
}

Problem

Two users editing the same document. User A types fast. User B sees User A's cursor jumping around — sometimes appearing a few characters ahead, sometimes behind. The cursor position was getting out of sync with the document state.

What I Tried

Tried debouncing cursor position updates. That reduced network traffic but made the lag worse. Tried increasing Yjs update frequency — more accurate but ate bandwidth.

Actual Fix

The issue was that cursor positions were being sent as absolute offsets. When the document changed (other user typed), the offset became stale. The fix is to use Yjs's built-in awareness protocol which handles relative positions:

// Proper Yjs collaboration setup
import * as Y from "yjs"
import { WebsocketProvider } from "y-websocket"
import Collaboration from "@tiptap/extension-collaboration"
import CollaborationCursor from "@tiptap/extension-collaboration-cursor"

const doc = new Y.Doc()

// WebSocket provider handles sync + awareness
const provider = new WebsocketProvider(
  "wss://sync.example.com",
  `doc-${documentId}`,
  doc
)

const editor = useEditor({
  extensions: [
    StarterKit,
    Collaboration.configure({ document: doc }),
    CollaborationCursor.configure({
      provider,
      user: {
        name: currentUser.name,
        color: currentUser.color, // Give each user a unique color
      },
    }),
  ],
  // Don't set initial content when using Collaboration
  // The document loads from the Yjs provider
})

Real-time Collaboration with Yjs

The server side is minimal. You need a WebSocket server that relays Yjs updates. I use the y-websocket server:

// server.js — Yjs WebSocket server
import { Doc, applyUpdate } from "yjs"
import { setupWSConnection } from "y-websocket/bin/utils"
import { WebSocketServer } from "ws"
import { LeveldbPersistence } from "y-leveldb"

const wss = new WebSocketServer({ port: 1234 })
const persistence = new LeveldbPersistence("./yjs-data")

wss.on("connection", (ws, req) => {
  const docId = new URL(req.url, "http://localhost").searchParams.get("doc")

  // Load from persistence
  persistence.getYDoc(docId).then((doc) => {
    setupWSConnection(ws, req, { doc })
  })
})

console.log("Yjs sync server running on :1234")

Custom Node Extensions

Tiptap's extension API is clean. Here's a custom callout block:

// extensions/callout.ts
import { Node, mergeAttributes } from "@tiptap/core"

export interface CalloutOptions {
  HTMLAttributes: Record
}

export const Callout = Node.create({
  name: "callout",
  group: "block",
  content: "block+",
  defining: true,

  addAttributes() {
    return {
      type: {
        default: "info",
        parseHTML: (el) => el.getAttribute("data-type"),
        renderHTML: (attrs) => ({ "data-type": attrs.type }),
      },
    }
  },

  parseHTML() {
    return [{ tag: 'div[data-callout]' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ["div", mergeAttributes(HTMLAttributes, { "data-callout": "" }), 0]
  },

  addCommands() {
    return {
      setCallout: (type) => ({ commands }) =>
        commands.wrapIn(this.name, { type }),
      toggleCallout: (type) => ({ commands }) =>
        commands.toggleWrap(this.name, { type }),
      unsetCallout: () => ({ commands }) =>
        commands.lift(this.name),
    }
  },

  addKeyboardShortcuts() {
    return {
      "Mod-Shift-c": () => this.editor.commands.toggleCallout("info"),
    }
  },
})

// React component for rendering in the editor
// components/CalloutView.tsx
import { NodeViewWrapper } from "@tiptap/react"

export function CalloutView({ node, updateAttributes }) {
  const bgColors = {
    info: "bg-blue-50 border-blue-400",
    warning: "bg-yellow-50 border-yellow-400",
    error: "bg-red-50 border-red-400",
  }

  return (
    <NodeViewWrapper className={`my-4 p-4 rounded border-l-4 ${bgColors[node.attrs.type]}`}>
      <select
        value={node.attrs.type}
        onChange={(e) => updateAttributes({ type: e.target.value })}
        className="mb-2 text-sm"
      >
        <option value="info">Info</option>
        <option value="warning">Warning</option>
        <option value="error">Error</option>
      </select>
    </NodeViewWrapper>
  )
}

Problem

Our legal contract editor handles documents with 100K+ words. At around 50K words, typing latency increased to 200ms+ per keystroke. The editor felt sluggish and unusable.

Actual Fix

Three optimizations that brought performance back:

// 1. Lazy-load heavy extensions
const editor = useEditor({
  extensions: [
    StarterKit.configure({
      // Disable extensions you don't need
      blockquote: false,
      codeBlock: false,
      horizontalRule: false,
    }),
    // Only load table when user needs it
    // LazyTableExtension loads on demand
  ],
})

// 2. Use shouldCreateNodeView to limit rendered node views
// Only render node views that are in the viewport
const LazyImage = Image.extend({
  addNodeView() {
    return ({ node, getPos }) => {
      // Only create DOM element when in viewport
      const observer = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) {
          // Render the actual image
          renderImage(node, getPos)
          observer.disconnect()
        }
      })
      const container = document.createElement("div")
      container.className = "image-placeholder"
      observer.observe(container)
      return { dom: container }
    }
  },
})

// 3. Debounce onUpdate for auto-save
onUpdate: ({ editor }) => {
  clearTimeout(saveTimer)
  saveTimer = setTimeout(() => {
    saveDocument(editor.getJSON())
  }, 1000) // Save at most once per second
}

What I Learned

Wrapping Up

After building rich text editors with Slate, Quill, and ProseMirror directly, Tiptap 3.0 is the clear winner. The extension API lets you build Notion-like features without fighting the framework. The Yjs integration makes collaborative editing work out of the box. And the performance optimizations in 3.0 mean it handles real-world document sizes. If you're building any kind of editor in 2026, start with Tiptap.

Related Articles