Rich-Text-Editor mit Claude Code, Tiptap und React bauen
Praxisguide für einen React Rich-Text-Editor mit Claude Code, Tiptap, Toolbar, JSON/HTML-Speicherung und Sanitizing.
Ein Rich-Text-Editor wirkt wie ein normales Formularfeld mit ein paar Buttons. In einer echten Anwendung ist er aber ein Daten- und Sicherheitsproblem: Welches Format wird gespeichert, wie wird eingefügtes HTML gereinigt, welche Links sind erlaubt, wie werden Bilder begrenzt, was passiert auf mobilen Tastaturen und wie rendert dieselbe Eingabe später auf einer öffentlichen Seite?
Dieser Artikel zeigt einen pragmatischen Weg mit Claude Code. Wir wählen Tiptap, begrenzen die Änderung auf eine Komponente, speichern JSON und sanitisiertes HTML und prüfen die typischen Fehler. Die offizielle Tiptap React-Dokumentation nutzt @tiptap/react, @tiptap/pm und @tiptap/starter-kit. StarterKit liefert Absätze, Überschriften, Fett, Kursiv und Listen, also genau genug für eine erste CMS-Version.
Warum Tiptap statt direktem contenteditable
contenteditable ist nur die rohe Browser-Funktion. MDN dokumentiert, dass eingefügter Inhalt bei contenteditable="true" seine Formatierung behält, während plaintext-only sie entfernt. Wer darauf direkt baut, muss Auswahl, Undo-Historie, Paste-Regeln, Commands, Accessibility und Browserunterschiede selbst pflegen.
Lexical ist ebenfalls stark. Die offizielle Seite Lexical beschreibt es als leichtgewichtiges, modulares Editor-Framework mit Paketen für Listen, Links und Tabellen. Für sehr individuelle Nodes, Chat-Eingaben oder komplexe Editor-States ist es interessant. Für ein Blog-CMS, eine Knowledge Base oder Produktbeschreibungen ist Tiptap am Anfang meist schneller erklärbar und leichter mit Claude Code zu kontrollieren.
| Kriterium | Tiptap | Lexical |
|---|---|---|
| Erste Version | Schnell mit StarterKit | Mehr Plugin- und Command-Design |
| Speicherung | JSON und HTML klar | EditorState-Strategie nötig |
| Erweiterung | Extensions | Sehr gut für Custom Nodes |
| Claude Code | Einfach zu begrenzen | Stark bei sehr detaillierten Anforderungen |
Installation und klarer Auftrag
Installiere zuerst die Abhängigkeiten. Collaboration, Kommentare, Mentions und Slash-Menüs gehören nicht in die erste Iteration. Sie verändern Schema, Rechte und Migrationen. Zuerst müssen Editieren, Speichern, Laden, Vorschau und Sanitizing stabil sein.
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image @tiptap/extension-character-count dompurify
Die aktuelle Claude Code-Dokumentation beschreibt Claude Code als agentisches Coding-Tool, das Codebases liest, Dateien bearbeitet, Befehle ausführt und Entwicklungstools integriert. Gib ihm deshalb einen überprüfbaren Auftrag:
Baue einen Rich-Text-Editor mit Tiptap, React und TypeScript.
Ändere nur src/components/RichTextEditor.tsx.
Unterstütze bold, italic, H2/H3, Listen, Links, Bild-URLs, JSON-Ausgabe und sanitisiertes HTML.
Lehne Link- und Bild-URLs ab, die nicht http oder https verwenden.
Nutze DOMPurify vor Preview oder Speicherung.
Gib am Ende eine manuelle Testcheckliste aus.
Für den Einstieg in Arbeitsweise und Grenzen passt der Claude Code Getting Started Guide. Wenn der Editor an eine API sendet, lies zusätzlich die Formularvalidierung. Markdown-Import oder -Export sollte getrennt bleiben und mit dem Markdown Processing Guide geplant werden.
Kopierbare React/TypeScript-Komponente
Die Komponente enthält Toolbar, URL-Prüfung, Zeichenzähler, JSON-Ausgabe, sanitisiertes HTML und lokalen Draft-Speicher. Tailwind-Klassen sind nur Styling und können ersetzt werden.
"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 Props = {
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;
for (const candidate of [trimmed, `https://${trimmed}`]) {
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...</p>",
maxCharacters = 8000,
onChange,
}: Props) {
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 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 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">
<div className="flex flex-wrap gap-1 border-b 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 onClick={() => {
const input = window.prompt("Link URL", "https://");
const href = input ? normalizeHttpUrl(input) : null;
if (!href) return window.alert("Use an http or https URL.");
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
}}>Link</ToolButton>
<ToolButton onClick={() => {
const input = window.prompt("Image URL", "https://");
const src = input ? normalizeHttpUrl(input) : null;
if (!src) return window.alert("Use an http or https image URL.");
editor.chain().focus().setImage({ src, alt: "" }).run();
}}>Image</ToolButton>
</div>
<EditorContent editor={editor} />
<footer className="flex items-center justify-between gap-3 border-t p-3 text-sm">
<span>{characters}/{maxCharacters} characters</span>
<button type="button" onClick={saveDraft} className="rounded bg-slate-900 px-3 py-2 text-white">Save draft</button>
{lastSaved && <span>HTML: {lastSaved.html.length} bytes</span>}
</footer>
</section>
);
}
function ToolButton({ active, onClick, children }: { active?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button type="button" aria-pressed={active} onClick={onClick} className={active ? "rounded border bg-sky-100 px-2 py-1" : "rounded border bg-white px-2 py-1"}>
{children}
</button>
);
}
JSON oder HTML speichern und laden
Speichere am Anfang drei Werte: JSON zum erneuten Bearbeiten, sanitisiertes HTML für die Ausgabe und Plain Text für Suche, RSS, Meta Description und Längenprüfung. Die Tiptap-Ausgabe-Dokumentation zeigt JSON/HTML-Ausgabe und betont, dass Nutzereingaben unabhängig vom Format validiert werden müssen.
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>
<RichTextEditor initialContent={draft?.json ?? "<p>New article</p>"} onChange={setDraft} />
<article dangerouslySetInnerHTML={{ __html: draft?.html ?? "" }} />
</main>
);
}
Sicherheit und Review
Das offizielle DOMPurify-Repository beschreibt DOMPurify als XSS-Sanitizer für HTML, MathML und SVG. Trotzdem brauchst du serverseitige Prüfung: Login, Besitzrechte, HTML-Größe, Plain-Text-Größe, erlaubte Tags, URL-Schemata und Bild-Domains. Client-Sanitizing ist nur eine Schicht.
flowchart LR
A["Editor UI"] --> B["Tiptap JSON"]
A --> C["Sanitized HTML"]
B --> D["Database draft"]
C --> D
D --> E["Public page"]
E --> F["Search / RSS / CTA"]
Lass Claude Code nach der Implementierung eine zweite Review-Runde schreiben. Sie sollte javascript:-Links, data:-Bilder, Rich-Paste, große Payloads, mobilen Fokus, Tastaturbedienung, Speichern/Laden und öffentliches Rendering prüfen.
Anwendungsfälle
Erster Fall: Blog-CMS. JSON dient zum erneuten Bearbeiten, HTML zum Rendern, Plain Text für Suche und SEO. Zweiter Fall: interne Knowledge Base. Überschriften, Listen, Code-Blöcke und Links reichen oft aus. Dritter Fall: Ecommerce-Produkttexte. Rich Text verbessert Verkaufstexte, aber HTML und Bilder müssen begrenzt werden. Vierter Fall: KI-Entwürfe. Von KI erzeugtes HTML ist ebenfalls nicht vertrauenswürdig und muss durch dieselbe Validierung.
Häufige Fehler
Nur HTML zu speichern ist bequem, erschwert aber spätere Migration, Markdown-Export, Inhaltsverzeichnisse und Collaboration. Nur clientseitig zu sanitizen ist riskant, weil APIs direkt aufgerufen werden können. Mobile Tests dürfen nicht fehlen: virtuelle Tastatur, Auswahl, lange URLs und Bilder brechen häufig zuerst dort. Füge Tabellen, Embeds und Mentions erst hinzu, wenn ein klarer Produktnutzen existiert.
Monetarisierung und Testergebnis
Ein Rich-Text-Editor beschleunigt die Verbesserung von SEO-Artikeln, Produktseiten, Trainingsmaterial und Help-Center-Inhalten. Für wiederholbare Prozesse helfen Claude Code Training und Beratung sowie Claude Code Produkte und Templates.
Im Test war der wichtigste Schritt, zuerst den Payload zu definieren: JSON, sanitisiertes HTML, Plain Text und URL-Validierung. Damit erzeugte Claude Code kleinere, besser prüfbare Änderungen. Vor Veröffentlichung prüfe ich formatiertes Einfügen, javascript:-Links, lange Texte, mobile Breite, Speichern, Neuladen und öffentliches Rendering.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Permission Safety Ladder: Zugriff kontrolliert erweitern
Von read-only zu begrenzten Änderungen, Prüfbefehlen und Deploy-Checks mit klarer Kontrolle.
Claude Code Small PR Proof Pack: kleine Änderungen reviewbar machen
Ein Proof Pack für Claude-Code-PRs: Diff, Checks, öffentliche URL, CTA-Pfad und Rollback.
Claude-Code-Review-Gate vor dem Commit
Vor dem Commit mit Claude Code prüfen: Diff, Build, öffentliche URL, Gumroad-Links, Beratung-CTA, fehlende Tests und fremde Dateien.