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.

  • namerich-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.

~/.claude/skills/rich-text-editors/SKILL.md

--- name: rich-text-editors description: Builds rich text editors on Tiptap 3, Lexical, or ProseMirror — choosing the library, defining a strict schema, writing extensions and node… license: MIT ---

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

NeedPick
React/Vue/Svelte app, standard formatting plus custom nodes, fastest to shipTiptap 3 (ProseMirror underneath, MIT core)
Meta-style plugin model, React-first, fine-grained node classes, large docsLexical
Unusual document model, maximum control, you own the toolbar and pluginsProseMirror directly
Plain comments with bold/italic/links onlyA textarea with markdown + server render, or Tiptap with a tiny schema
Block-based Notion-like UX out of the boxBlockNote (built on Tiptap)

Do not hand-roll contenteditable with document.execCommand — it is deprecated, inconsistent across browsers, and produces unsanitizable markup.

Storage contract

StoreWhy
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/FloatingMenu import from @tiptap/react/menus (or @tiptap/vue-3/menus); Tippy.js replaced by Floating UI (tippyOptions → options; add @floating-ui/dom).
  • CollaborationCursor became CollaborationCaret; history is UndoRedo in 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

  • LexicalComposer with initialConfig: { 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 with editorState.toJSON(); debounce saves).
  • Mutate only inside editor.update(() => ...); read inside editorState.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 any innerHTML/dangerouslySetInnerHTML/v-html.
  • Block javascript: and data: 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, class unless 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 (transformPastedHTML in 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’s MarkdownShortcutPlugin; keep them, users expect them.

Custom nodes

  • Prefer a node with plain renderHTML/parseHTML first; 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 parseHTML rules 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: false missing in SSR apps → hydration error on load.
  • Setting content on every React render resets the cursor; pass initial content once and use editor.commands.setContent only 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; use editor.isEmpty for validation.
  • Character limits: CharacterCount extension with limit enforces 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

  1. Paste from Google Docs, Word, and a web page: output contains only schema nodes, no inline styles or spans with classes.
  2. XSS test: submit JSON with a link javascript:alert(1), an image with onerror, and raw <script> in a text node via the API (bypassing the editor); rendered page executes nothing and the server rejects or strips them.
  3. Round-trip: load stored JSON → edit → save → reload; no content loss, cursor stable.
  4. SSR page loads without hydration warnings; keyboard shortcuts and undo work.
  5. 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 123 lines · 5.2 KB
# 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.

esc