Cómo implementar un editor de texto enriquecido con Claude Code
Guía práctica para crear un editor rich text con Claude Code, Tiptap, React, guardado JSON/HTML y sanitización segura.
Un editor de texto enriquecido parece una mejora visual de un formulario, pero en producción toca seguridad, persistencia, SEO y experiencia móvil. Hay que decidir si se guarda HTML, JSON o ambos; cómo se limpian los fragmentos pegados desde Word o Google Docs; qué ocurre con enlaces javascript:; cómo se limitan imágenes externas; y cómo se renderiza el contenido en una página pública sin abrir una puerta a XSS.
En esta guía usaremos Claude Code de forma acotada: primero definimos la arquitectura, después pedimos una implementación con Tiptap, React y TypeScript, y por último revisamos guardado, carga, sanitización y casos de uso. La documentación oficial de Tiptap para React indica el flujo base con @tiptap/react, @tiptap/pm y @tiptap/starter-kit. StarterKit cubre lo básico: párrafos, títulos, negrita, cursiva y listas.
Por qué elegir Tiptap
La alternativa de escribir sobre contenteditable directamente no es buena para un producto serio. MDN documenta que contenteditable="true" conserva formato pegado, mientras contenteditable="plaintext-only" lo elimina. Esa diferencia ya muestra el tipo de comportamiento que tendrías que normalizar a mano: selección, historial, pegado, comandos, accesibilidad y compatibilidad móvil.
Lexical también es una opción fuerte. El sitio oficial de Lexical lo presenta como un framework ligero y modular para editores, con paquetes para listas, enlaces y tablas. Lo elegiría para nodos muy personalizados o experiencias tipo chat. Para un CMS, una base de conocimiento o una descripción de producto, Tiptap suele dar una primera versión más rápida y fácil de explicar a Claude Code.
| Criterio | Tiptap | Lexical |
|---|---|---|
| Primera versión | Rápida con StarterKit | Requiere diseñar plugins y comandos |
| Guardado | JSON y HTML son directos | Hay que definir estrategia de EditorState |
| Extensión | Extensions | Nodos y comandos personalizados |
| Trabajo con Claude Code | Fácil de limitar con docs | Mejor con requisitos muy detallados |
Instalación y prompt para Claude Code
Instala primero las dependencias. No incluyas colaboración, comentarios, menciones o menús slash en la primera iteración. Esas funciones cambian el esquema y la seguridad; conviene agregarlas solo cuando editar, guardar, previsualizar y recuperar ya funcionan.
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image @tiptap/extension-character-count dompurify
La documentación actual de Claude Code lo describe como una herramienta agentic que lee el codebase, edita archivos, ejecuta comandos e integra herramientas de desarrollo. Eso es útil, pero solo si el pedido tiene límites:
Crea un editor rich text con Tiptap, React y TypeScript.
Modifica solo src/components/RichTextEditor.tsx.
Debe soportar bold, italic, H2/H3, listas, enlaces, URL de imagen, salida JSON y HTML sanitizado.
Rechaza enlaces e imágenes que no usen http o https.
Usa DOMPurify antes de previsualizar o guardar HTML.
Incluye una checklist manual de pruebas al final.
Si el equipo aún no tiene hábitos de trabajo con Claude Code, empieza por la guía inicial. Si el editor se envía a un formulario o API, conecta la tarea con la validación de formularios. Para Markdown, separa esa conversión y consulta la guía de procesamiento Markdown.
Componente React/TypeScript listo para copiar
El componente siguiente incluye barra de herramientas, validación de URL, contador, salida JSON, HTML sanitizado y guardado local. Usa clases Tailwind, pero puedes reemplazarlas sin cambiar la lógica.
"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>
);
}
Guardar y cargar JSON o HTML
El patrón inicial más sano es guardar tres salidas. JSON sirve para volver a editar, HTML sanitizado sirve para renderizar, y texto plano sirve para extractos, buscador, RSS y límites de longitud. La guía de salida JSON/HTML de Tiptap también recuerda que el formato no elimina el riesgo: siempre hay que validar input.
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>
);
}
En el servidor repite la validación. Comprueba usuario, permisos, tamaño de HTML, tamaño de texto, etiquetas permitidas, protocolos de URL y dominio de imágenes. El repositorio oficial de DOMPurify lo define como sanitizer XSS para HTML, MathML y SVG, pero no convierte una lista de etiquetas demasiado amplia en una política segura.
Diagrama de revisión
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"]
La revisión debe seguir el dato, no solo la interfaz. Comprueba pegado desde Google Docs, enlace javascript:, imagen data:, payload grande, foco de teclado, selección móvil, estado deshabilitado del botón guardar y renderizado público. Pide a Claude Code una segunda pasada solo de review con riesgos, archivos tocados y pruebas manuales.
Casos de uso reales
Primer caso: CMS de blog. Guarda JSON para reeditar, HTML sanitizado para publicar y texto plano para meta description, RSS y búsqueda. Segundo caso: base de conocimiento interna. Títulos, listas, enlaces y bloques de código cubren mucho sin añadir comentarios o flujos de aprobación. Tercer caso: ecommerce. Las listas y enlaces mejoran la ficha de producto, pero hay que limitar HTML, imágenes y etiquetas. Cuarto caso: edición de borradores generados por IA. El HTML de una IA también es input no confiable y debe pasar por la misma sanitización.
Errores frecuentes
No guardes solo HTML si el contenido será importante. Es cómodo al principio, pero complica migraciones, exportación Markdown, índices y rediseños. No confíes solo en el cliente: la API puede recibir peticiones directas. No retrases la prueba móvil: teclado virtual, URL largas, imágenes y selección de texto suelen fallar ahí. Y no añadas tablas, embeds, menciones o colaboración sin una razón de producto clara.
CTA de monetización y resultado probado
Un editor rich text acorta el ciclo de mejora de contenido: SEO, páginas de producto, materiales de formación, documentación y CTAs. Para convertirlo en un sistema repetible, revisa formación y consultoría de Claude Code o productos y plantillas.
Al probar este flujo, el mejor resultado vino de definir primero el payload: JSON, HTML sanitizado, texto plano y validación de URL. Claude Code generó un cambio más pequeño y fácil de revisar que cuando se empezaba por botones visuales. Antes de publicar, prueba pegado con formato, enlace javascript:, texto largo, ancho móvil, guardado, recarga y render público.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.