Rich Text Editors skill for Claude Code
Tight schemas, JSON storage, sanitized output. Claude picks the editor library for the product's real needs, keeps the schema limited to supported formatting, stores JSON as the source of truth, and renders HTML on the server through an allowlist sanitizer. It cleans pasted Word and Docs content, uploads pasted images, sets SSR flags that prevent hydration errors, and wires Yjs collaboration with per-document authorization.
- name
rich-text-editors - size200 lines · ≈2,500 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds rich text editors on Tiptap 3, Lexical, or ProseMirror — choosing the library, defining a strict schema, writing extensions and node views, storing JSON rather than HTML, server-side rendering and sanitizing output with DOMPurify or sanitize-html, paste cleanup, image uploads, markdown shortcuts, SSR hydration settings, and real-time collaboration with Yjs and Hocuspocus. Use when adding a WYSIWYG or comment editor, migrating Tiptap 2 to 3, pasted content breaks layout, stored HTML causes XSS risk, or when adding multiplayer editing and cursors. Try asking
- Add a Tiptap editor for blog posts that stores JSON and renders safe HTML
- Pasting from Google Docs breaks our editor layout — clean it up
- Make our document editor collaborative with live cursors
Use it when
- Adding a WYSIWYG, comment or document editor to an app
- Migrating from Tiptap 2 to 3 or between editor libraries
- Stored editor HTML is rendered with innerHTML or v-html
- Adding real-time collaboration or presence to an editor
Skip it when
- You only need a validated form with plain text fields — use forms-validation
- The task is a sync engine for app data rather than documents — use offline-sync
How to check the result
Paste from Word, Docs and web pages yields only schema nodes, XSS payloads sent straight to the API render inert, and stored JSON round-trips without loss or hydration warnings.
The common mistake it prevents
Saving the editor's HTML from the client and rendering it later as trusted markup, which turns every post into a potential XSS vector.
A production editor has a schema that only allows what the product supports, stores a structured document (JSON) as the source of truth, renders and sanitizes HTML on the server, cleans pasted content through the schema, and treats every byte from the client as untrusted.
Step 0 — inspect
npm ls @tiptap/react @tiptap/core @tiptap/starter-kit lexical @lexical/react prosemirror-model yjs @hocuspocus/provider
grep -rn "dangerouslySetInnerHTML\|v-html\|innerHTML" src app | head # where editor output is rendered
grep -rn "getHTML()\|getJSON()\|editorState.toJSON" src app | head # what gets stored
Find out what the product needs: formatting set, embeds (images, mentions, links, tables, code), comments, collaboration, markdown import/export, mobile support, and where content is displayed (web, email, native app).
Library choice
| Need | Pick |
|---|---|
| React/Vue/Svelte app, standard formatting plus custom nodes, fastest to ship | Tiptap 3 (ProseMirror underneath, MIT core) |
| Meta-style plugin model, React-first, fine-grained node classes, large docs | Lexical |
| Unusual document model, maximum control, you own the toolbar and plugins | ProseMirror directly |
| Plain comments with bold/italic/links only | A textarea with markdown + server render, or Tiptap with a tiny schema |
| Block-based Notion-like UX out of the box | BlockNote (built on Tiptap) |
Do not hand-roll contenteditable with document.execCommand — it is deprecated,
inconsistent across browsers, and produces unsanitizable markup.
Storage contract
| Store | Why |
|---|---|
JSON document (Tiptap editor.getJSON(), Lexical editorState.toJSON()) | Source of truth; validates against schema; migratable |
| Rendered, sanitized HTML (optional cache column) | Fast display, email, search snippets — regenerated from JSON |
| Plain text (optional) | Search indexing, notifications, previews |
- Validate JSON on the server against the same schema before saving (Tiptap:
getSchema(extensions)+Node.fromJSON(schema, json)throws on invalid content). - Version the document format (
{ schemaVersion: 3, doc }) so you can migrate stored content when nodes change. - Never store client-produced HTML as trusted. If you must accept HTML (imports, legacy), sanitize on the server with an allowlist.
Tiptap 3 setup (React)
import { useEditor, EditorContent } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Image from "@tiptap/extension-image";
export function PostEditor({ initial, onChange }: Props) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] }, // only what the design supports
link: { openOnClick: false, autolink: true, protocols: ["http", "https", "mailto"] },
}),
Placeholder.configure({ placeholder: "Write something…" }),
Image.configure({ allowBase64: false }), // force uploads, no data: URIs in docs
],
content: initial, // JSON, not HTML
immediatelyRender: false, // required with SSR (Next.js, Remix) to avoid hydration mismatch
shouldRerenderOnTransaction: false, // v3 default; read state with useEditorState for toolbars
onUpdate: ({ editor }) => onChange(editor.getJSON()),
});
return (
<>
{editor && <BubbleMenu editor={editor}><Toolbar editor={editor} /></BubbleMenu>}
<EditorContent editor={editor} className="prose" />
</>
);
}
Tiptap 2 → 3 changes that break code:
- StarterKit now includes Link and Underline — remove separate registrations or
disable the bundled ones (
StarterKit.configure({ link: false })) to avoid duplicate extension warnings. BubbleMenu/FloatingMenuimport from@tiptap/react/menus(or@tiptap/vue-3/menus); Tippy.js replaced by Floating UI (tippyOptions→options; add@floating-ui/dom).CollaborationCursorbecameCollaborationCaret; history isUndoRedoin StarterKit (disable it with collaboration:StarterKit.configure({ undoRedo: false })).- Toolbars that show active marks: use
useEditorState({ editor, selector })rather than re-rendering on every transaction.
Read the official v2 → v3 upgrade guide for the full list before migrating.
Lexical essentials
LexicalComposerwithinitialConfig: { namespace, nodes: [HeadingNode, ListNode, ListItemNode, LinkNode], onError, theme }.- Register only the nodes you need; unknown nodes in stored JSON throw on parse.
RichTextPlugin,HistoryPlugin,ListPlugin,LinkPlugin,OnChangePlugin(serialize witheditorState.toJSON(); debounce saves).- Mutate only inside
editor.update(() => ...); read insideeditorState.read(...). - Server HTML:
@lexical/headless+$generateHtmlFromNodes(needs a DOM shim such as jsdom/happy-dom on Node), then sanitize.
Rendering output safely
// server: JSON → HTML → sanitize → cache
import { generateHTML } from "@tiptap/html"; // works on the server in v3
import sanitizeHtml from "sanitize-html";
const html = generateHTML(doc, extensions); // same extension list as the editor
const safe = sanitizeHtml(html, {
allowedTags: ["p", "h2", "h3", "strong", "em", "u", "s", "a", "ul", "ol", "li", "blockquote", "code", "pre", "img", "br", "hr"],
allowedAttributes: { a: ["href", "rel", "target"], img: ["src", "alt", "width", "height"] },
allowedSchemes: ["http", "https", "mailto"],
transformTags: { a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer nofollow" }) },
});
- In the browser, DOMPurify (
DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })) before anyinnerHTML/dangerouslySetInnerHTML/v-html. - Block
javascript:anddata:URLs on links and images even if the editor’s link extension validates them — the API can be called without the editor. - Images: only from your upload domain/CDN; strip
on*attributes,style,classunless allowlisted. - Add a Content-Security-Policy to pages that display user content (see
security-headers).
Paste and input handling
- Pasted HTML is parsed through the schema: unsupported tags are dropped, which is the main defense against Word/Google Docs junk. Keep the schema tight.
- Strip inline styles and classes on paste (
transformPastedHTMLin ProseMirror/Tiptap editorProps) if marks with attributes (color, font) are not product features. - Pasted images arrive as files: intercept (
handlePaste/handleDrop), upload, then insert the node with the returned URL; show a placeholder node while uploading and remove it on failure. - Markdown shortcuts (
##,-,>,```) come from input rules in StarterKit and Lexical’sMarkdownShortcutPlugin; keep them, users expect them.
Custom nodes
- Prefer a node with plain
renderHTML/parseHTMLfirst; reach for a React/Vue node view only for interactive content (embeds, mention chips with popovers, callouts with pickers). Node views are costly in long documents. - Define
parseHTMLrules for every node so copy/paste between documents round-trips. - Atom nodes (
atom: true) for embeds that should be selected and deleted as one unit. - Mentions: suggestion utility → popup list → insert node with
{ id, label }; resolve the user server-side when rendering, never trust the label for permissions.
Collaboration
Yjs is the default CRDT for both Tiptap (@tiptap/extension-collaboration +
@tiptap/extension-collaboration-caret) and Lexical (@lexical/yjs + CollaborationPlugin).
Server options: Hocuspocus (Tiptap’s open-source Yjs server), y-websocket, Liveblocks,
or Tiptap Cloud. For the setup, persistence, auth and snapshot details read
references/collaboration-yjs.md when adding multiplayer.
Gotchas
immediatelyRender: falsemissing in SSR apps → hydration error on load.- Setting
contenton every React render resets the cursor; pass initial content once and useeditor.commands.setContentonly when switching documents. - Saving on every keystroke: debounce (500–1000 ms) and also save on blur and
beforeunload/visibilitychange. - Empty editor returns
<p></p>, not an empty string; useeditor.isEmptyfor validation. - Character limits:
CharacterCountextension withlimitenforces in the editor; the server must enforce too. - Mobile Safari/Android IME: test composition (Japanese, Chinese, Android Gboard autocorrect); avoid key handlers that intercept during composition.
- Large documents (>50 pages) need care: avoid node views in repeated nodes, and do not
getJSON()on every transaction.
Verification
- Paste from Google Docs, Word, and a web page: output contains only schema nodes, no inline styles or spans with classes.
- XSS test: submit JSON with a link
javascript:alert(1), an image withonerror, and raw<script>in a text node via the API (bypassing the editor); rendered page executes nothing and the server rejects or strips them. - Round-trip: load stored JSON → edit → save → reload; no content loss, cursor stable.
- SSR page loads without hydration warnings; keyboard shortcuts and undo work.
- Collaboration (if any): two browsers edit the same paragraph simultaneously; both converge, and reconnecting after offline edits merges without loss.
Report format
**Editor:** Tiptap 3.x (StarterKit headings 2–3, link, image upload, mentions)
**Storage:** JSON (schemaVersion 2) + sanitized HTML cache via sanitize-html allowlist
**Paste:** styles stripped, images uploaded to /uploads with placeholder node
**Security:** server schema validation, link schemes http/https/mailto, CSP on /posts
**Verified:** paste matrix, XSS payloads via API, round-trip, no hydration warnings Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/collaboration-yjs.md
# Collaborative editing with Yjs
Read this when adding multiplayer editing, presence cursors, or offline sync to a
Tiptap or Lexical editor.
## Architecture
```
browser A ──┐ ┌── persistence (Postgres bytea / S3 / Redis)
browser B ──┼── WebSocket provider ── Yjs server (Hocuspocus / y-websocket / hosted)
browser C ──┘ (+ awareness: cursors, names) └── auth hook, webhooks, snapshots
```
- The Yjs document (`Y.Doc`) is the source of truth while collaborating. The editor
binds to a `Y.XmlFragment` inside it.
- Awareness (cursor positions, user name/color, "is typing") is ephemeral and is not
persisted.
- Persist the binary Yjs update (`Y.encodeStateAsUpdate(doc)`), not editor JSON, as the
collaborative source. Also derive JSON/HTML for search and display on the server
(debounced, e.g. every 10 s or on last disconnect).
## Server choice
| Option | When |
|---|---|
| Hocuspocus (self-hosted, MIT) | Tiptap apps, want auth hooks, persistence extensions, webhooks |
| y-websocket server | Minimal setup, prototypes, custom persistence |
| Liveblocks / Tiptap Cloud / other hosted | No infra team; pay per MAU/doc |
| y-webrtc | Demos only; no authority, no persistence, NAT issues |
## Tiptap 3 client
```tsx
import * as Y from "yjs";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCaret from "@tiptap/extension-collaboration-caret";
const ydoc = useMemo(() => new Y.Doc(), [docId]);
const provider = useMemo(
() => new HocuspocusProvider({ url: WS_URL, name: `doc:${docId}`, document: ydoc, token: () => getSessionToken() }),
[docId, ydoc],
);
useEffect(() => () => { provider.destroy(); ydoc.destroy(); }, [provider, ydoc]);
const editor = useEditor({
extensions: [
StarterKit.configure({ undoRedo: false }), // Yjs provides its own undo manager
Collaboration.configure({ document: ydoc }),
CollaborationCaret.configure({ provider, user: { name: me.name, color: me.color } }),
],
immediatelyRender: false,
});
```
- Do not pass `content` when collaborating: initial content comes from the server
document. Seed new documents server-side (load JSON → convert to Yjs once) to avoid
every client inserting the template and duplicating it.
- Check the Tiptap docs for the exact option names of the installed version; the
history/undo extension and caret extension names changed between v2 and v3.
## Lexical client
- `CollaborationPlugin` from `@lexical/react` with a `providerFactory(id, yjsDocMap)`
returning a `WebsocketProvider` (y-websocket) or Hocuspocus provider.
- Set `initialConfig.editorState: null` so Lexical lets Yjs populate the document.
- Use `shouldBootstrap` only for the very first client of a brand-new document.
## Hocuspocus server essentials
```ts
import { Server } from "@hocuspocus/server";
import { Database } from "@hocuspocus/extension-database";
const server = new Server({
port: 1234,
async onAuthenticate({ token, documentName }) {
const user = await verifySession(token); // reject unknown tokens
const docId = documentName.replace(/^doc:/, "");
const role = await permissions.roleFor(user.id, docId);
if (!role) throw new Error("Forbidden");
return { user, readOnly: role === "viewer" }; // read-only connections ignore writes
},
extensions: [
new Database({
fetch: async ({ documentName }) => db.docs.loadYjsState(documentName), // Uint8Array | null
store: async ({ documentName, state }) => db.docs.saveYjsState(documentName, state),
}),
],
});
server.listen();
```
Verify the hook names and how read-only is signaled against the installed Hocuspocus
version's docs; the shape above is the pattern.
## Security rules
- Authenticate on connect and authorize per document; a WebSocket URL is not a secret.
- Enforce read-only on the server, not only by making the editor non-editable.
- Validate on the server what you derive from the Yjs doc (schema check of the generated
JSON) before using it for HTML rendering, emails, or search.
- Rate-limit update size and frequency per connection; reject documents above a size cap.
## Operations
- Snapshots/versions: periodically store `Y.encodeStateAsUpdate` with a timestamp for
history and restore; Yjs garbage collection must be disabled (`gc: false`) if you want
to render past versions with `Y.snapshot`.
- Scaling Hocuspocus horizontally: use the Redis extension so instances share updates
for the same document; or route all clients of one document to one instance.
- Offline: `y-indexeddb` persists the doc locally; edits sync and merge on reconnect.
- Memory: unload documents from the server after the last client disconnects (default
behavior with a debounce); watch for documents that never unload.
## Testing convergence
1. Two browsers, same document: type in the same paragraph at the same time — both end
with identical content.
2. Disconnect one (DevTools offline), edit both, reconnect — edits merge, nothing lost.
3. Viewer role: edits made through the console are rejected by the server and do not
reach other clients.
4. Restart the server mid-session: clients reconnect and the persisted state loads.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.