Claude Code with Tiptap: Schema-First Editor Without the Footguns
Why Tiptap without CLAUDE.md ships editors that lose content on paste
Tiptap looks like a simple component until the first user pastes a Word document into it. Then the schema rejects half the nodes, the marks collapse into plain text, the formatting the user expected to keep disappears, and the bug report says "the editor deleted my work." The library is not at fault. ProseMirror, which Tiptap wraps, is the most rigorous rich-text model in the JavaScript ecosystem. It refuses to render content that does not match the schema, which is the right behaviour for a document model and the wrong behaviour for a user who pasted a heading inside a list item.
Claude Code does not know any of this by default. It generates new Editor({ extensions: [StarterKit] }), ships a content prop wired to React state, and stops. The result works for the demo input the engineer typed and breaks on every realistic paste. The most common Claude defaults that break Tiptap in production: extensions added in the wrong order so configuration overrides silently drop, custom nodes without a parseHTML so the editor cannot round-trip the content, NodeViews that re-render on every keystroke because the React component is not memoised, paste handlers that strip too much or sanitise too little, content controlled from React state which fights the editor's own state, and collaborative providers wired before the document is ready so the first user sees an empty canvas.
This guide covers the CLAUDE.md configuration that locks Claude Code into Tiptap's schema-first model: extensions declared with explicit ordering, custom nodes scaffolded from the CLI with parseHTML and renderHTML in the same file, NodeViews wrapped in React.memo with a stable selector, paste rules registered as ProseMirror plugins rather than React event handlers, content sanitised at the schema layer not the input layer, and collaborative editing initialised with the Yjs document loaded before the editor mounts. For the React rendering patterns Tiptap depends on, Claude Code with React covers the component model that pairs cleanly with NodeViews.
The Tiptap CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Tiptap integration it needs to declare: the Tiptap version line, the React or vanilla bindings, the extension list with ordering, the schema design rules, the NodeView authoring pattern, the paste handling discipline, the collaboration setup, and the hard rules that block the mistakes Claude makes most often.
# Tiptap rules
## Stack
- @tiptap/core ^2.x with @tiptap/react ^2.x bindings
- @tiptap/starter-kit for baseline schema (paragraph, heading, list, mark, history)
- @tiptap/pm/* for direct ProseMirror access (plugins, state, transform)
- @tiptap/extension-collaboration + Yjs for multi-user editing
- Editor mounted once per route, NEVER re-created on parent re-render
## Project structure
- src/editor/ , editor configuration and extensions
- src/editor/extensions/ , one folder per custom node or mark
- src/editor/nodeviews/ , React components for NodeViews
- src/editor/plugins/ , ProseMirror plugins (paste, decoration, key)
- src/editor/schema/ , schema serialisation helpers (HTML, JSON, Markdown)
- src/editor/collab/ , Yjs document provider + awareness setup
## Extension ordering (MANDATORY)
- StarterKit FIRST so custom extensions can override its defaults
- Custom nodes AFTER StarterKit in the array
- Marks AFTER nodes
- Decoration plugins LAST
- NEVER duplicate an extension (StarterKit already includes History, Heading, Bold, etc.)
## Schema design (MANDATORY)
- Every custom node declares: name, group, content, parseHTML, renderHTML
- group MUST be one of: 'block', 'inline', 'list', 'tableRow', 'tableCell'
- content MUST match what the node can hold ('paragraph+', 'inline*', 'block+')
- parseHTML MUST list every HTML element shape the node can be imported from
- renderHTML MUST produce HTML that parseHTML can read back (round-trip safe)
## NodeView authoring (MANDATORY for any custom node with React UI)
- ReactNodeViewRenderer for React-rendered nodes
- Wrap the component in React.memo
- NEVER read editor state from the component, read attrs from node prop
- ALWAYS forward the contentDOMRef for editable content
- selectorFn passed to React.memo MUST compare node.attrs, NOT the whole node
## Paste handling (MANDATORY)
- Paste rules as ProseMirror plugins, registered via addProseMirrorPlugins()
- NEVER read clipboardData from React onPaste, the editor consumes it first
- clipboardSerializer overrides for custom copy behaviour
- clipboardTextParser overrides for plain-text paste with structure inference
## Content state (MANDATORY)
- Editor content is UNCONTROLLED, do NOT pass a content prop bound to React state
- Listen to onUpdate to mirror changes outward, NEVER force content back in
- For initial content, pass it ONCE via the content config option
- For external updates (e.g. server push), use editor.commands.setContent()
## Collaboration (when using Yjs)
- ALWAYS load the Yjs document BEFORE creating the Editor
- Pass the Y.XmlFragment via the Collaboration extension config
- Awareness state set AFTER the editor mounts, never inside the editor config
- One Y.Doc per document, never share across routes
## Hard rules
- NEVER pass content as a controlled prop tied to React state
- NEVER re-create the Editor on every render, use useEditor with stable deps
- NEVER add an extension that StarterKit already includes (double-registration crashes)
- NEVER read clipboardData from React onPaste handlers
- NEVER skip parseHTML on a custom node (the node cannot survive copy-paste)
- NEVER mutate editor.state directly, always dispatch a transaction
- NEVER ship without a paste sanitiser if the editor accepts user-submitted content
- ALWAYS destroy the editor in the useEditor return path on unmount
Five rules here prevent the majority of incidents Claude generates without them.
The extension ordering rule prevents silent overrides. Tiptap merges extension configuration in array order. If you put a custom Heading extension before StarterKit, StarterKit's Heading wins and your customisation does nothing. The rule "StarterKit first, custom extensions after" makes the override semantics explicit. The "never duplicate an extension" clause prevents the most confusing crash: registering Heading separately when StarterKit already includes it, which throws at editor creation with a "schema contains duplicate node" error.
The schema-first rule prevents the paste-loses-content bug. ProseMirror only renders content the schema permits. A custom Callout node that declares content: 'paragraph+' accepts paragraphs and rejects images, lists, and tables. When the user pastes a callout that contained a list, ProseMirror unwraps the list into paragraphs because that is what the schema allows. The rule "content MUST match what the node can hold" makes the engineer think about acceptable children before writing the node, instead of debugging silent content loss later.
The NodeView memoisation rule prevents the typing lag that turns a 1ms keystroke into a 30ms re-render. Tiptap re-renders the NodeView component on every editor transaction. Without React.memo and a selector that compares only node.attrs, a document with 50 callout blocks re-renders all 50 React subtrees on every keystroke. The rule "wrap the component in React.memo" and the selector clause make the editor stay fast as the document grows.
The uncontrolled content rule prevents the most common React-Tiptap fight. Engineers reach for content={state} and onUpdate={(e) => setState(e.getHTML())} because that is the React idiom. Tiptap fights back: setting content forces a full document replacement, which destroys the selection, scrolls the user to the top, and re-renders every NodeView. The rule "editor content is UNCONTROLLED" forces Claude to use editor.commands.setContent() for genuine external updates and to leave the editor's internal state alone otherwise.
The paste rules as plugins rule prevents the half-sanitised-content trap. React onPaste runs before the editor consumes the paste, but reading clipboardData there gives you raw HTML before ProseMirror has applied its schema. The right place to intercept is a ProseMirror plugin via addProseMirrorPlugins(), where the paste is a transaction you can transform with full schema awareness.
Install and editor setup
Install the Tiptap core, React bindings, and StarterKit:
pnpm add @tiptap/core @tiptap/react @tiptap/starter-kit
For ProseMirror plugin access:
pnpm add @tiptap/pm
Create the editor with the stable useEditor pattern:
// src/editor/Editor.tsx
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Callout } from './extensions/callout';
import { CalloutNodeView } from './nodeviews/CalloutNodeView';
type Props = {
initialContent: string;
onChange: (html: string) => void;
};
export function Editor({ initialContent, onChange }: Props) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Callout.configure({
nodeView: CalloutNodeView,
}),
],
content: initialContent,
onUpdate: ({ editor }) => onChange(editor.getHTML()),
editorProps: {
attributes: {
class: 'prose prose-sm focus:outline-none max-w-none',
},
},
});
if (!editor) return null;
return <EditorContent editor={editor} />;
}
Three things in this snippet that the CLAUDE.md rule enforces. The content prop is passed once via the editor config, not bound to React state. The onUpdate callback mirrors changes outward but does not feed them back in. The useEditor hook handles editor lifecycle including destroy on unmount, which prevents the memory leak that double-mounted editors cause on route transitions.
Authoring a custom node with schema and NodeView
The custom node is the unit of Tiptap extensibility. A correctly-authored node has a schema declaration, a parseHTML for round-tripping, a renderHTML for serialisation, and optionally a NodeView for React-rendered UI.
A complete Callout extension:
// src/editor/extensions/callout.ts
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { CalloutNodeView } from '../nodeviews/CalloutNodeView';
export interface CalloutOptions {
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
callout: {
setCallout: (attrs?: { variant?: string }) => ReturnType;
toggleCallout: (attrs?: { variant?: string }) => ReturnType;
};
}
}
export const Callout = Node.create<CalloutOptions>({
name: 'callout',
group: 'block',
content: 'paragraph+',
defining: true,
addOptions() {
return { HTMLAttributes: {} };
},
addAttributes() {
return {
variant: {
default: 'info',
parseHTML: (el) => el.getAttribute('data-variant') ?? 'info',
renderHTML: (attrs) => ({ 'data-variant': attrs.variant }),
},
};
},
parseHTML() {
return [
{ tag: 'div[data-type="callout"]' },
{ tag: 'aside.callout' },
];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'callout',
}),
0,
];
},
addCommands() {
return {
setCallout:
(attrs) =>
({ commands }) =>
commands.wrapIn(this.name, attrs),
toggleCallout:
(attrs) =>
({ commands, state }) => {
const isActive = state.selection.$from.parent.type.name === this.name;
return isActive
? commands.lift(this.name)
: commands.wrapIn(this.name, attrs);
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CalloutNodeView);
},
});
The schema fields work together. group: 'block' says callouts are block-level, so they can sit at the document root. content: 'paragraph+' says callouts must contain at least one paragraph. defining: true makes the node a "container" boundary, so when the user presses backspace at the start of the first paragraph inside a callout, ProseMirror lifts the paragraph out instead of merging it with the previous block. parseHTML declares two HTML shapes that can be imported as callouts. renderHTML produces the canonical HTML output. The 0 in the render array is the content hole, where ProseMirror puts the inner content.
The NodeView is the React component that renders the editable callout:
// src/editor/nodeviews/CalloutNodeView.tsx
import { memo } from 'react';
import { NodeViewWrapper, NodeViewContent, type NodeViewProps } from '@tiptap/react';
function CalloutNodeViewImpl({ node }: NodeViewProps) {
const variant = (node.attrs.variant as string) ?? 'info';
return (
<NodeViewWrapper
as="div"
className={`callout callout-${variant}`}
data-variant={variant}
>
<span className="callout-icon" aria-hidden="true">
{variant === 'warn' ? '!' : 'i'}
</span>
<NodeViewContent className="callout-body" as="div" />
</NodeViewWrapper>
);
}
export const CalloutNodeView = memo(
CalloutNodeViewImpl,
(prev, next) =>
prev.node.attrs.variant === next.node.attrs.variant &&
prev.selected === next.selected,
);
Three patterns in this NodeView that the CLAUDE.md rule enforces. The component reads node.attrs for configuration, not editor state. NodeViewContent is the editable region where ProseMirror manages content. The React.memo comparator compares only the attributes that affect rendering, so the component does not re-render on unrelated transactions.
Add an extension scaffolding rule to CLAUDE.md:
## Extension scaffolding template (CONCRETE)
A new custom node goes in src/editor/extensions/<name>.ts with this shape:
Node.create<Options>({
name: '<name>',
group: 'block' | 'inline' | 'list' | ...,
content: '<content expression>',
defining?: true,
addAttributes() { return { ... } },
parseHTML() { return [ ... ] },
renderHTML({ HTMLAttributes }) { return [ ... ] },
addCommands() { return { ... } },
addNodeView?() { return ReactNodeViewRenderer(...) },
})
ALWAYS: parseHTML for every shape the node can be imported from
NEVER: ship a node without renderHTML producing a parseHTML-compatible shape
Paste handling with ProseMirror plugins
Paste is where rich-text editors die. The user pastes a snippet from Google Docs, the snippet has inline styles, nested tables, custom Microsoft tags, and the editor must decide what to keep, what to convert, and what to discard. Tiptap handles the common cases via StarterKit, but any non-trivial editor needs a paste plugin.
A paste plugin via the ProseMirror plugin API:
// src/editor/plugins/paste-sanitiser.ts
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { DOMParser, Slice } from '@tiptap/pm/model';
export const pasteSanitiserKey = new PluginKey('pasteSanitiser');
export function pasteSanitiser() {
return new Plugin({
key: pasteSanitiserKey,
props: {
transformPastedHTML(html) {
const stripped = html
.replace(/<\!--[\s\S]*?-->/g, '')
.replace(/style="[^"]*"/g, '')
.replace(/class="[^"]*"/g, '');
const allowed = /<(\/?(p|h1|h2|h3|ul|ol|li|strong|em|a|code|pre|blockquote|br)([\s>]|$)[^>]*)>/g;
return stripped.replace(/<[^>]+>/g, (tag) => (tag.match(allowed) ? tag : ''));
},
transformPasted(slice) {
return slice;
},
},
});
}
Register the plugin via the extension:
// src/editor/extensions/paste.ts
import { Extension } from '@tiptap/core';
import { pasteSanitiser } from '../plugins/paste-sanitiser';
export const PasteSanitiser = Extension.create({
name: 'pasteSanitiser',
addProseMirrorPlugins() {
return [pasteSanitiser()];
},
});
The transformPastedHTML hook runs before ProseMirror parses the pasted HTML into a schema-compliant slice. This is where you strip inline styles, drop class attributes, and constrain the tag set to elements your schema can represent. The transformPasted hook runs after parsing, where you can manipulate the resulting Slice if you need schema-aware transformations.
Add a paste sanitiser rule to CLAUDE.md:
## Paste sanitiser
- Implement transformPastedHTML for tag-level filtering
- Implement transformPasted for slice-level manipulation
- Tag allowlist MUST match the schema's accepted nodes and marks
- NEVER read clipboardData from React onPaste (the editor consumes it first)
- ALWAYS test with a paste from Google Docs, Word, and Notion
Collaborative editing with Yjs
Tiptap collaboration sits on top of Yjs, a CRDT library that handles concurrent edits. The setup has three moving parts: a Y.Doc that holds the shared state, a provider that syncs the doc with other clients, and the Tiptap Collaboration extension that wires the editor to a Y.XmlFragment inside the doc.
// src/editor/collab/setup.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
export type CollabHandle = {
ydoc: Y.Doc;
provider: WebsocketProvider;
fragment: Y.XmlFragment;
};
export function createCollabHandle(documentId: string, wsUrl: string): CollabHandle {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(wsUrl, documentId, ydoc, {
connect: true,
});
const fragment = ydoc.getXmlFragment('document');
return { ydoc, provider, fragment };
}
The editor wires the fragment via the Collaboration extension and the user identity via CollaborationCursor:
// src/editor/CollabEditor.tsx
'use client';
import { useEffect, useState } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import { createCollabHandle, type CollabHandle } from './collab/setup';
type Props = {
documentId: string;
wsUrl: string;
user: { name: string; color: string };
};
export function CollabEditor({ documentId, wsUrl, user }: Props) {
const [handle, setHandle] = useState<CollabHandle | null>(null);
useEffect(() => {
const h = createCollabHandle(documentId, wsUrl);
setHandle(h);
return () => {
h.provider.destroy();
h.ydoc.destroy();
};
}, [documentId, wsUrl]);
const editor = useEditor(
{
extensions: handle
? [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: handle.ydoc, field: 'document' }),
CollaborationCursor.configure({ provider: handle.provider, user }),
]
: [],
editable: handle !== null,
},
[handle],
);
if (!handle || !editor) return null;
return <EditorContent editor={editor} />;
}
Three things in this snippet that the CLAUDE.md rule enforces. The Yjs handle is created before the editor extensions reference it. The StarterKit's history extension is disabled because Yjs handles undo via its own UndoManager. The editor is rebuilt when handle changes, so a new document ID gets a clean editor with a clean Y.Doc.
For the Next.js routing layer that hosts the editor and the websocket endpoint, Claude Code with Next.js covers the App Router patterns that pair cleanly with collaborative editing.
Common Claude Code mistakes with Tiptap
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Controlled content prop tied to React state
Claude generates:
const [content, setContent] = useState('<p></p>');
const editor = useEditor({
extensions: [StarterKit],
content,
onUpdate: ({ editor }) => setContent(editor.getHTML()),
});
The useState triggers a re-render on every keystroke, which causes useEditor to see new content value, which forces a setContent, which destroys selection and re-renders the document. Typing lags severely.
Correct pattern:
const editor = useEditor({
extensions: [StarterKit],
content: initialContent,
onUpdate: ({ editor }) => onChange(editor.getHTML()),
});
Initial content once, change notified outward, never fed back.
2. Custom node without parseHTML
Claude generates Node.create({ name: 'callout', renderHTML: () => ['div', 0] }) and stops. The node renders, but a copy-paste round trip drops it because the parser does not know how to read it back.
Correct pattern: every custom node has both parseHTML and renderHTML, and the shapes must round-trip.
3. NodeView without React.memo
Claude generates a NodeView component that reads from useState or useEditor. The component re-renders on every transaction. A document with 30 NodeViews drops 60fps to 20fps.
Correct pattern: React.memo with a selector that compares only the attributes that affect rendering.
4. Extension duplication
Claude generates extensions: [StarterKit, Heading, Bold, Italic]. StarterKit already includes Heading, Bold, and Italic. The editor crashes at creation with a schema duplication error.
Correct pattern: configure StarterKit's included extensions via StarterKit.configure({ heading: {...}, bold: {...} }) or disable them with StarterKit.configure({ heading: false }) if you want to register a custom replacement.
5. Paste handler on the React component
Claude generates <EditorContent editor={editor} onPaste={(e) => sanitize(e.clipboardData)} />. The editor consumes the paste event before React's handler runs, so the sanitiser never fires.
Correct pattern: register a ProseMirror plugin via addProseMirrorPlugins() and implement transformPastedHTML.
6. Collab editor mounted before the Y.Doc is ready
Claude generates the editor synchronously with Collaboration.configure({ document: new Y.Doc() }), then attaches the provider later. The provider syncs into a doc the editor has already snapshotted, so remote changes do not appear.
Correct pattern: create the Y.Doc and provider in a useEffect, gate the editor on the handle being ready, rebuild the editor when the handle changes.
Add this list to CLAUDE.md with the corrected form for each.
Permission hooks for Tiptap projects
A Tiptap project has CLI surface for installing extensions, generating boilerplate, and running tests. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pnpm tiptap*)",
"Bash(pnpm test*)",
"Bash(pnpm vitest*)",
"Bash(pnpm add @tiptap/*)"
],
"deny": [
"Bash(pnpm remove @tiptap/core*)",
"Bash(pnpm remove @tiptap/react*)",
"Bash(pnpm remove @tiptap/starter-kit*)",
"Bash(rm -rf src/editor*)"
]
}
}
Adding extensions and running tests are safe and reversible. Removing core packages or wiping the editor directory needs explicit confirmation. The deny list forces Claude to surface those operations as prompts.
Building Tiptap editors that survive real paste
The Tiptap CLAUDE.md in this guide produces editors where every custom node has a schema declaration with a round-trip-safe parseHTML and renderHTML, every NodeView is memoised against the attributes that actually affect rendering, paste is sanitised at the ProseMirror plugin layer with a tag allowlist that matches the schema, content is uncontrolled with changes mirrored outward rather than fed back, and collaboration is initialised with the Y.Doc loaded before the editor mounts.
The underlying principle is the same as any complex client-side framework with Claude Code. Tiptap without a CLAUDE.md produces editors that work for the engineer's hello-world content and break on the first real document: paste from Word strips formatting Claude did not anticipate, typing lag because NodeViews re-render on every transaction, content overwrites because the React state and editor state fight, schema duplicates because StarterKit already included that extension.
For the React component patterns the editor depends on, Claude Code with React covers the rendering model that pairs cleanly with NodeViews. For the state management layer that holds document metadata alongside the editor content, Claude Code with Zustand covers the store patterns that work well with editor instances. Claudify includes a Tiptap-specific CLAUDE.md template with the extension ordering rules, the custom node scaffolding pattern, the NodeView memoisation contract, the paste sanitiser plugin, the Yjs collaboration setup, and all six common-mistake rules pre-configured.
Get Claudify. Ship Tiptap editors that respect the schema.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify