Tips & Tricks (Actualizado: 2/6/2026)

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.

Cómo implementar un editor de texto enriquecido con Claude Code

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.

CriterioTiptapLexical
Primera versiónRápida con StarterKitRequiere diseñar plugins y comandos
GuardadoJSON y HTML son directosHay que definir estrategia de EditorState
ExtensiónExtensionsNodos y comandos personalizados
Trabajo con Claude CodeFácil de limitar con docsMejor 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.

#Claude Code #editor rich text #React #Tiptap #gestión de contenido
Gratis

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.