How to Build a Rich Text Editor with Claude Code
Build a React rich text editor with Claude Code, Tiptap, toolbar controls, JSON/HTML saving, and safe sanitization.
A rich text editor looks like a normal form field until you ship it. Then the real questions appear: which format should be saved, how pasted HTML is cleaned, whether links can inject unsafe protocols, how images are constrained, how mobile selection behaves, and how the same content renders on the public page. A simple contenteditable demo is not enough for a CMS, knowledge base, ecommerce product description, or AI writing workflow.
This guide shows a pragmatic way to use Claude Code to build the feature without letting the implementation drift. We will choose Tiptap, define the boundaries before asking Claude Code to edit, implement a React/TypeScript component with a toolbar, save both JSON and sanitized HTML, and review the failure cases that usually cause production incidents.
The current Claude Code overview describes Claude Code as an agentic coding tool that can read a codebase, edit files, run commands, and integrate with development tools. That is useful for editor work because the implementation crosses component code, persistence, security review, and manual browser checks. The important move is to give Claude Code a narrow contract, not a vague request like “add an editor.”
Why Tiptap Instead of Raw contenteditable
The browser contenteditable attribute can make an element editable, but MDN documents behavior that matters for production: pasted content into contenteditable="true" keeps formatting, while contenteditable="plaintext-only" removes it. That difference alone is enough to avoid building a rich editor by hand. You would need to normalize selections, pasted HTML, commands, undo history, links, mobile keyboard behavior, and schema rules across browsers.
Tiptap is a practical layer on top of ProseMirror. The official React installation guide installs @tiptap/react, @tiptap/pm, and @tiptap/starter-kit; StarterKit gives you common building blocks such as paragraphs, headings, bold, italic, and lists. Tiptap also exposes getJSON() and getHTML(), which makes the storage story easy to explain to Claude Code and to future maintainers.
Lexical is also a serious option. The official Lexical site describes it as a lean, modular text editor framework with packages for features such as lists, links, and tables. I would reach for Lexical when the product needs custom editor states, complex nodes, or chat-style inputs. For a beginner-friendly CMS field with toolbar buttons and JSON/HTML output, Tiptap usually reaches a working baseline faster.
| Decision point | Tiptap | Lexical |
|---|---|---|
| First working React editor | Fast with StarterKit | More plugin and command design |
| Storage | JSON and HTML are straightforward | EditorState strategy must be designed |
| Customization | Extension-based | Excellent for custom nodes and commands |
| Claude Code promptability | Easy to constrain with docs and examples | Better when requirements are very explicit |
Install and Give Claude Code a Tight Brief
Install the editor packages and DOMPurify first. The code below is intentionally modest: no collaboration, no comments, no slash command menu, and no custom database adapter. Those can come later after the save and render path is verified.
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image @tiptap/extension-character-count dompurify
Use a prompt that names the files, the allowed features, the storage format, and the security requirements. This is the kind of request I would give Claude Code in an existing React or Next.js project:
Build a React/TypeScript rich text editor with Tiptap.
Only edit src/components/RichTextEditor.tsx.
Support bold, italic, H2/H3, bullet list, ordered list, links, image URLs, JSON output, and sanitized HTML output.
Reject link and image URLs unless they use http or https.
Use DOMPurify before previewing or saving HTML.
Set immediatelyRender: false so the component is safe in Next.js client components.
Return a short manual test checklist after the code change.
If your project is new to Claude Code, pair this with the Claude Code getting started guide. If the editor will feed a form or API route, also read the form validation guide. For Markdown import or export, connect the decision to the Markdown processing guide instead of mixing conversion logic into the editor component.
Copy-Paste React/TypeScript Component
This component includes a toolbar, URL validation, character count, sanitized HTML, JSON output, and local draft saving. It uses Tailwind classes for readability; if your project does not use Tailwind, replace the className strings and keep the editor logic.
"use client";
import type { Editor, JSONContent } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import CharacterCount from "@tiptap/extension-character-count";
import DOMPurify from "dompurify";
import type { ReactNode } from "react";
import { useState } from "react";
export type SavedEditorContent = {
json: JSONContent;
html: string;
plainText: string;
};
type RichTextEditorProps = {
initialContent?: JSONContent | string;
maxCharacters?: number;
onChange?: (content: SavedEditorContent) => void;
};
const allowedTags = [
"p",
"br",
"strong",
"em",
"s",
"h2",
"h3",
"ul",
"ol",
"li",
"blockquote",
"code",
"pre",
"a",
"img",
];
const allowedAttrs = ["href", "src", "alt", "title", "target", "rel"];
function normalizeHttpUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
const candidates = [trimmed, `https://${trimmed}`];
for (const candidate of candidates) {
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
} catch {
// Try the next candidate.
}
}
return null;
}
export function sanitizeEditorHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
ALLOW_DATA_ATTR: false,
});
}
function buildPayload(editor: Editor): SavedEditorContent {
return {
json: editor.getJSON(),
html: sanitizeEditorHtml(editor.getHTML()),
plainText: editor.getText(),
};
}
export function RichTextEditor({
initialContent = "<p>Start writing the body here.</p>",
maxCharacters = 8000,
onChange,
}: RichTextEditorProps) {
const [lastSaved, setLastSaved] = useState<SavedEditorContent | null>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
}),
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: {
rel: "noopener noreferrer nofollow",
target: "_blank",
},
}),
Image.configure({
allowBase64: false,
}),
CharacterCount.configure({
limit: maxCharacters,
}),
],
content: initialContent,
immediatelyRender: false,
editorProps: {
attributes: {
class:
"min-h-[260px] rounded-b-md border border-t-0 border-slate-300 bg-white p-4 text-base leading-7 outline-none focus:ring-2 focus:ring-sky-500",
"aria-label": "Rich text body",
},
transformPastedHTML(html) {
return sanitizeEditorHtml(html);
},
},
onUpdate({ editor }) {
const payload = buildPayload(editor);
setLastSaved(payload);
onChange?.(payload);
},
});
if (!editor) return null;
const characters = editor.storage.characterCount.characters();
const isOverLimit = characters > maxCharacters;
const saveDraft = () => {
const payload = buildPayload(editor);
setLastSaved(payload);
onChange?.(payload);
window.localStorage.setItem("article-draft", JSON.stringify(payload));
};
return (
<section className="rounded-md border border-slate-300 bg-slate-50">
<Toolbar editor={editor} />
<EditorContent editor={editor} />
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 p-3 text-sm">
<span className={isOverLimit ? "text-red-600" : "text-slate-600"}>
{characters}/{maxCharacters} characters
</span>
<button
type="button"
onClick={saveDraft}
disabled={isOverLimit}
className="rounded bg-slate-900 px-3 py-2 font-medium text-white disabled:cursor-not-allowed disabled:bg-slate-400"
>
Save draft
</button>
{lastSaved && (
<span className="text-slate-500">
Saved HTML: {lastSaved.html.length} bytes
</span>
)}
</footer>
</section>
);
}
function Toolbar({ editor }: { editor: Editor }) {
const setLink = () => {
const current = editor.getAttributes("link").href as string | undefined;
const input = window.prompt("Link URL", current ?? "https://");
if (input === null) return;
if (input.trim() === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
const href = normalizeHttpUrl(input);
if (!href) {
window.alert("Use an http or https URL.");
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
};
const addImage = () => {
const input = window.prompt("Image URL", "https://");
if (input === null) return;
const src = normalizeHttpUrl(input);
if (!src) {
window.alert("Use an http or https image URL.");
return;
}
editor.chain().focus().setImage({ src, alt: "" }).run();
};
return (
<div className="flex flex-wrap gap-1 rounded-t-md border-b border-slate-300 bg-white p-2" role="toolbar" aria-label="Formatting toolbar">
<ToolButton active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()}>
B
</ToolButton>
<ToolButton active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()}>
I
</ToolButton>
<ToolButton active={editor.isActive("heading", { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
H2
</ToolButton>
<ToolButton active={editor.isActive("heading", { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
H3
</ToolButton>
<ToolButton active={editor.isActive("bulletList")} onClick={() => editor.chain().focus().toggleBulletList().run()}>
List
</ToolButton>
<ToolButton active={editor.isActive("orderedList")} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
1.
</ToolButton>
<ToolButton active={editor.isActive("link")} onClick={setLink}>
Link
</ToolButton>
<ToolButton onClick={addImage}>Image</ToolButton>
</div>
);
}
function ToolButton({
active,
onClick,
children,
}: {
active?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`rounded border px-2.5 py-1.5 text-sm font-medium ${
active
? "border-sky-600 bg-sky-100 text-sky-800"
: "border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
}`}
>
{children}
</button>
);
}
Save and Load JSON or HTML
The safest beginner pattern is to store JSON for re-editing, sanitized HTML for rendering, and plain text for snippets, search indexes, and length checks. The official Tiptap output guide shows change listeners with editor.getJSON() and discusses server-side rendering options. It also makes a security point that should shape your API: malicious users can send bad content whether the payload is JSON or HTML, so input validation is always required.
import { useEffect, useState } from "react";
import { RichTextEditor, type SavedEditorContent } from "./RichTextEditor";
export function ArticleEditorPage() {
const [draft, setDraft] = useState<SavedEditorContent | null>(null);
useEffect(() => {
const raw = window.localStorage.getItem("article-draft");
if (raw) setDraft(JSON.parse(raw) as SavedEditorContent);
}, []);
return (
<main className="mx-auto max-w-3xl space-y-6 p-6">
<RichTextEditor
initialContent={draft?.json ?? "<p>New article body</p>"}
onChange={setDraft}
/>
<article
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: draft?.html ?? "" }}
/>
</main>
);
}
For a server route, do not trust the component just because it already sanitized the preview. Attackers can bypass the browser. Validate the shape, size, ownership, and allowed URL schemes again before storing anything.
type EditorPayload = {
json: unknown;
html: string;
plainText: string;
};
function isEditorPayload(value: unknown): value is EditorPayload {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return (
typeof record.html === "string" &&
typeof record.plainText === "string" &&
record.html.length <= 200_000 &&
record.plainText.length <= 20_000 &&
typeof record.json === "object" &&
record.json !== null
);
}
export async function saveEditorPayload(value: unknown) {
if (!isEditorPayload(value)) {
throw new Error("Invalid editor payload");
}
return {
json: value.json,
html: value.html,
plainText: value.plainText.trim(),
savedAt: new Date().toISOString(),
};
}
Security and Review Diagram
DOMPurify’s official repository describes it as a DOM-only XSS sanitizer for HTML, MathML, and SVG. Use it as one layer, not as a reason to allow every tag. Keep the allowed list small until a real use case requires more. Most product editors do not need iframe, inline style, arbitrary data-*, or pasted event handlers.
flowchart LR
A["Editor UI"] --> B["Tiptap JSON"]
A --> C["Sanitized HTML"]
B --> D["Database draft"]
C --> D
D --> E["Preview / public page"]
E --> F["Search index / RSS / CTA"]
Ask Claude Code for a second review pass after the implementation. The review should check unsafe protocols, paste behavior, oversized payloads, mobile selection, keyboard focus, disabled save state, preview rendering, and the API boundary. That second pass is especially useful because editor bugs often hide in the transition between UI state and stored content.
Use Cases That Justify the Work
The first use case is a blog CMS. Store Tiptap JSON for re-editing, sanitized HTML for public rendering, and plain text for meta descriptions, RSS, and search. If Markdown import matters, keep it as an explicit conversion step instead of turning the editor component into a parser.
The second use case is an internal knowledge base. Headings, lists, code blocks, and links usually cover most team documentation. Avoid comments, mentions, and approval workflows until the basic edit-save-search loop is reliable.
The third use case is ecommerce copy. Rich lists and links can improve product pages, but the public page must remain fast and safe. Validate HTML size, image URLs, and forbidden tags before a merchant can publish.
The fourth use case is AI-assisted writing. Claude or another model may generate a draft, but AI-generated HTML is still untrusted input. Run it through the same validation and sanitization path as human input, then add a human review for links, factual claims, and brand tone.
Pitfalls to Avoid
Do not store only HTML unless the field is truly disposable. HTML is easy to render but painful to migrate when you later need table of contents generation, collaboration, Markdown export, or a different design system. JSON plus sanitized HTML gives you more options.
Do not rely only on client-side sanitization. The API must assume hostile requests. Check body size, authentication, authorization, allowed tags, URL schemes, and the maximum plain-text length on the server.
Do not skip mobile testing. Rich text selection, the virtual keyboard, sticky toolbars, long URLs, and inserted images behave differently on small screens. A desktop-only check misses the bugs users notice first.
Do not let Claude Code add features without a product reason. Tables, embeds, slash commands, mentions, collaboration, and AI rewrite buttons are all possible, but each adds schema, security, and migration work.
Monetization CTA
A rich text editor affects revenue because it shortens the loop between idea, content update, SEO improvement, and conversion test. For ClaudeCodeLab, the same pattern supports article refreshes, product pages, training material, and knowledge-base content. Once the editor is stable, you can connect it to analytics and improve CTAs based on real behavior.
If you are building this for a business site, connect the editor work to Claude Code training and consultation or the Claude Code products and templates. The editor is the input surface; the revenue comes from publishing better pages, updating offers faster, and keeping implementation rules repeatable.
Tested Result Note
When I tested this flow on a small article editor, the biggest win came from deciding the payload before polishing the toolbar. Asking Claude Code to implement JSON, sanitized HTML, plain text, URL validation, and a manual test checklist produced a smaller and easier-to-review change than starting with visual controls.
Before publishing, run type checks, paste formatted content, try a javascript: link, insert a long image URL, save and reload the draft, preview on mobile width, and confirm the public renderer uses sanitized HTML only. Use the official Tiptap, DOMPurify, MDN, Lexical, and Claude Code documentation linked above as the source of truth when you extend this baseline.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.