Why I pick TipTap over raw ProseMirror for most product teams, plus schema design, Yjs collab, and serialization gotchas.
We shipped a contentEditable editor on a Thursday. By Friday morning a creator on the platform pasted a course outline from Google Docs, and the whole thing turned into one giant blockquote. The nested list collapsed. Inline images lost their alt text. Our support inbox lit up before standup. I’m not proud of this, but it’s the moment I stopped pretending contentEditable was good enough.
The fix wasn’t a clever regex. It was throwing out the editor and starting from a real schema.
OK so here’s my position. For most product teams building a rich text editor inside a SaaS product, TipTap is the right default. It sits on top of ProseMirror, gives you a sane React API, a plugin model, and a bunch of well-tested extensions out of the box. You keep ProseMirror’s rigor without writing two thousand lines of schema glue to get a working bullet list.
Raw ProseMirror is fine. It is also a ton of work. I’ve watched teams burn a quarter writing custom node views and input rules before they ship a single callout block. If your product genuinely needs that level of control, sure. If you need an editor that handles headings, lists, code blocks, mentions, and a few custom nodes, TipTap gets you there in a week.
The creator-economy platform I worked at had a half-dozen editor surfaces. We standardized on TipTap. The team that didn’t, the one shipping the page builder, paid for it for months.
There are real reasons to drop down to raw ProseMirror. You’re building Notion. You’re building a CMS for a publishing house. You need custom marks and nodes with constraints TipTap’s helpers cannot express, table editing with merged cells, inline citations with cross-references, a math node that round-trips LaTeX.
The way most teams arrive at “we need raw ProseMirror” is, someone read a blog post about flexibility, and now we’re three months in writing our own version of the TipTap node view system.
Treat the schema like a database migration. Every change to a node type, attribute, or mark has a forward path and a backfill story. Skip this and you’ll wake up one Tuesday with three months of saved documents that the new editor refuses to load.
Marks vs nodes is the call I see most teams get wrong. Marks are inline formatting on a range of text. Bold, italic, link. Nodes are block-level structures with their own children. Modeling a callout? It’s a node. Modeling a highlight color? It’s a mark. Get this wrong and your serializer will fight you forever.
Here’s a TipTap custom callout node we shipped on one of the editor surfaces.
import { mergeAttributes, Node } from "@tiptap/core"
export type CalloutVariant = "info" | "warn" | "success"
declare module "@tiptap/core" {
interface Commands<ReturnType> {
callout: {
setCallout: (attrs: { variant: CalloutVariant }) => ReturnType
toggleCallout: (attrs: { variant: CalloutVariant }) => ReturnType
}
}
}
export const CalloutNode = Node.create({
name: "callout",
group: "block",
content: "paragraph+",
defining: true,
addAttributes() {
return {
variant: {
default: "info" as CalloutVariant,
parseHTML: (el) => (el.getAttribute("data-variant") ?? "info") as CalloutVariant,
renderHTML: (attrs) => ({ "data-variant": attrs.variant }),
},
}
},
parseHTML() {
return [{ tag: 'div[data-type="callout"]' }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes({ "data-type": "callout" }, HTMLAttributes), 0]
},
addCommands() {
return {
setCallout:
(attrs) =>
({ commands }) =>
commands.wrapIn(this.name, attrs),
toggleCallout:
(attrs) =>
({ commands }) =>
commands.toggleWrap(this.name, attrs),
}
},
})
defining: true is the difference between a callout that survives a backspace at the start and one that collapses into the previous paragraph. Pick the wrong default and QA will file the same bug twelve times.
Version your serialized output. Store the ProseMirror JSON in your database, not HTML. JSON survives schema migrations. HTML does not.
If you need multi-user editing, Yjs is the play. CRDT-backed conflict-free merging without the pain of OT. y-prosemirror binds a Y.Doc to a ProseMirror state, and TipTap has a first-class extension for it.
Transport options. y-websocket is the easy choice. You run a small WebSocket server, clients sync deltas, awareness state piggybacks on the same connection. y-webrtc is fine for small groups but I’d rather route through my own auth, my own logging, my own kill switch. WebSocket behind your gateway. Pick that.
import { useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Collaboration from "@tiptap/extension-collaboration"
import CollaborationCursor from "@tiptap/extension-collaboration-cursor"
import * as Y from "yjs"
import { WebsocketProvider } from "y-websocket"
import { useEffect, useMemo } from "react"
type Args = {
docId: string
user: { id: string; name: string; color: string }
token: string
}
const WS_URL = process.env.NEXT_PUBLIC_COLLAB_WS!
export function useCollabEditor({ docId, user, token }: Args) {
const ydoc = useMemo(() => new Y.Doc(), [docId])
const provider = useMemo(() => {
const p = new WebsocketProvider(WS_URL, docId, ydoc, {
connect: false,
params: { token },
// jittered reconnect, capped, so a flap doesn't spin the server
maxBackoffTime: 30_000,
})
return p
}, [ydoc, docId, token])
useEffect(() => {
provider.connect()
return () => {
provider.disconnect()
provider.destroy()
ydoc.destroy()
}
}, [provider, ydoc])
const editor = useEditor(
{
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider,
user: { name: user.name, color: user.color },
}),
],
},
[ydoc, provider]
)
return { editor, provider }
}
StarterKit ships its own history, but you must disable it when Collaboration is on. Otherwise undo will undo other people’s edits. That’s the kind of bug that gets you tweeted at.
Keep awareness out of the Y.Doc. Cursors, selections, presence avatars, “is typing” indicators all live on provider.awareness. It’s volatile and ephemeral. Don’t persist it.
Store JSON. Render HTML. Avoid Markdown.
The ProseMirror doc JSON is the canonical form. It’s lossless across the editor’s schema, it survives migrations if you version your extensions, and it’s what Yjs is binding to under the hood. HTML is the export format for email, public page rendering, RSS, and accessibility tools. Generate it on the server from the JSON when you need it.
Markdown is the trap. Round-tripping doc to Markdown to doc will eat your custom nodes, your colors, your callouts, your image alt text. If you must support Markdown import, treat it as a one-way conversion at upload time, never as the storage format.
import { generateHTML } from "@tiptap/html"
import StarterKit from "@tiptap/starter-kit"
import { CalloutNode } from "./callout-node"
import createDOMPurify from "dompurify"
import { JSDOM } from "jsdom"
const window = new JSDOM("").window as unknown as Window
const DOMPurify = createDOMPurify(window)
const ALLOWED_TAGS = ["p", "h1", "h2", "h3", "ul", "ol", "li", "strong", "em", "a", "code", "pre", "div", "br"]
const ALLOWED_ATTR = ["href", "rel", "target", "data-type", "data-variant"]
export function renderEditorJsonToHtml(doc: unknown): string {
if (!doc || typeof doc !== "object") {
throw new Error("renderEditorJsonToHtml: invalid doc")
}
const raw = generateHTML(doc as any, [StarterKit, CalloutNode])
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ADD_ATTR: ["target"],
FORBID_TAGS: ["script", "style", "iframe"],
})
}
If you’re rendering inside an email client, run it through a second pass with a tighter allowlist. Email clients are their own circle of hell.
Back to the war story. The editor at the top was a homegrown contentEditable surface, maybe a thousand lines of handlers for beforeinput, paste, and keydown. Worked fine for typing. Fell over the moment anyone pasted anything from another app.
First instinct was to fix the paste. We added a regex pass on clipboardData.getData("text/html") that stripped inline styles and unknown tags. It helped the immediate bug but broke ordered lists from Notion, inline code from VS Code, and images from Slack, because Safari produced different HTML than Chrome and the regex couldn’t keep up. Chasing browser bugs with regex is a losing fight.
Real fix was to throw the whole thing out. We dropped in TipTap, ported existing documents through a one-shot migration script that parsed the legacy HTML into ProseMirror JSON, and wrote a single paste rule that ran every fragment through TipTap’s own parser. About a week of paired engineering and a Friday afternoon of manual QA. After that, paste worked.
import { Extension } from "@tiptap/core"
import { Plugin, PluginKey } from "@tiptap/pm/state"
export const SafePaste = Extension.create({
name: "safePaste",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("safe-paste"),
props: {
transformPastedHTML(html) {
// strip anything that looks like a tracking pixel or remote script
return html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/\s(on\w+)="[^"]*"/gi, "")
.replace(/<img[^>]+src="https?:\/\/[^"]*tracking[^"]*"[^>]*>/gi, "")
},
},
}),
]
},
})
The handler is small on purpose. TipTap parses the cleaned HTML against the schema. Anything the schema doesn’t know about gets dropped. That’s the point.
Other war story. A few months after we switched, we shipped collaborative editing. Looked great in the demo. Then a junior on my squad reported something weird. If you typed inside a nested list while a remote user typed at the top of the document, your cursor jumped to the end of the doc on every remote update. Every couple of seconds. Unusable.
First wrong fix was to save the local selection before applying a remote step and restore it after. Sort of worked, until the local user was mid-IME composition. Japanese and Korean typists hated it.
Real fix was to read the transaction’s meta. y-prosemirror tags remote transactions with a specific meta key, so the selection restore only needs to fire for local transactions that weren’t selection-only. About twenty lines, one paragraph in the editor extension. Two days of UX flapping before we found it. Standing rule, local state and remote state are different things. Boundaries that aren’t explicit will be wrong eventually.
Thanks for reading. If you’ve got thoughts, send them my way.