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

Membuat Rich Text Editor dengan Claude Code, Tiptap, dan React

Bangun rich text editor React dengan Claude Code, Tiptap, toolbar, penyimpanan JSON/HTML, dan sanitasi yang aman.

Membuat Rich Text Editor dengan Claude Code, Tiptap, dan React

Rich text editor terlihat seperti input biasa dengan tombol bold dan list. Namun begitu masuk produksi, fiturnya menyentuh penyimpanan, keamanan, SEO, preview, dan pengalaman mobile. Kita harus menentukan format yang disimpan, membersihkan HTML yang ditempel dari tool lain, memblokir link berbahaya, membatasi URL gambar, dan memastikan halaman publik hanya merender HTML yang aman.

Panduan ini memakai Claude Code dengan batas yang jelas. Kita memilih Tiptap, menulis prompt yang sempit, membuat komponen React/TypeScript yang bisa disalin, lalu membahas save/load JSON atau HTML, sanitasi, use case, dan pitfall. Dokumentasi resmi Tiptap React memasang @tiptap/react, @tiptap/pm, dan @tiptap/starter-kit. StarterKit sudah memberi paragraph, heading, bold, italic, dan list.

Kenapa memilih Tiptap

Membangun langsung di atas contenteditable tampak cepat, tetapi terlalu banyak detail browser yang harus diurus sendiri. MDN menjelaskan bahwa contenteditable="true" mempertahankan formatting saat paste, sedangkan plaintext-only menghapus formatting. Untuk produk nyata, selection, undo, paste rule, command, accessibility, dan mobile behavior lebih baik diserahkan ke editor framework.

Lexical juga layak dipakai. Situs resmi Lexical menyebutnya framework editor yang ringan dan modular, dengan paket untuk list, link, dan table. Saya akan memilih Lexical untuk node yang sangat custom atau input bergaya chat. Untuk CMS, knowledge base, atau deskripsi produk, Tiptap biasanya lebih cepat memberi baseline yang stabil dan mudah diarahkan melalui Claude Code.

KriteriaTiptapLexical
Versi pertamaCepat dengan StarterKitPerlu desain plugin/command
PenyimpananJSON dan HTML jelasPerlu strategi EditorState
EkstensiExtensionKuat untuk custom node
Dengan Claude CodeMudah diberi batasBagus bila requirement sangat detail

Instalasi dan prompt Claude Code

Pasang dependency terlebih dahulu. Jangan mulai dengan collaboration, comment, mention, atau slash command. Fitur itu menambah schema, permission, dan migrasi. Versi pertama sebaiknya hanya memastikan edit, save, load, preview, dan sanitasi berjalan.

npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image @tiptap/extension-character-count dompurify

Dokumentasi Claude Code menjelaskan bahwa tool ini dapat membaca codebase, mengedit file, menjalankan command, dan terhubung dengan alat development. Jadi prompt harus spesifik:

Buat rich text editor React/TypeScript dengan Tiptap.
Ubah hanya src/components/RichTextEditor.tsx.
Dukung bold, italic, H2/H3, list, link, image URL, output JSON, dan output HTML yang sudah disanitasi.
Tolak link dan image URL selain http/https.
Gunakan DOMPurify sebelum preview atau save HTML.
Tambahkan checklist manual test.

Jika workflow Claude Code belum rapi, mulai dari panduan Claude Code. Jika editor mengirim data ke form atau API, hubungkan dengan validasi form. Jika butuh Markdown, pisahkan converter dan baca panduan Markdown.

Komponen React/TypeScript siap salin

Komponen ini berisi toolbar, validasi URL, character count, output JSON, HTML yang disanitasi, dan penyimpanan draft lokal. Class Tailwind hanya untuk styling; ganti sesuai sistem desain Anda.

"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>
  );
}

Save/load JSON atau HTML

Pola awal yang aman adalah menyimpan tiga nilai: JSON untuk diedit ulang, HTML yang sudah disanitasi untuk render, dan plain text untuk excerpt, search, RSS, meta description, dan validasi panjang. Panduan output JSON/HTML Tiptap juga menekankan bahwa input tetap harus divalidasi, baik formatnya JSON maupun HTML.

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>
  );
}

Keamanan dan review

Repository resmi DOMPurify menjelaskannya sebagai sanitizer XSS untuk HTML, MathML, dan SVG. Tetap ulangi validasi di server: user, permission, ukuran HTML, ukuran plain text, tag yang diizinkan, protokol URL, dan domain gambar. Client-side sanitization hanyalah satu lapisan.

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"]

Minta Claude Code membuat review pass kedua. Fokusnya bukan estetika toolbar, tetapi data boundary: link javascript:, image data:, paste HTML, payload besar, selection mobile, keyboard focus, tombol save, reload draft, dan public renderer.

Use case nyata

Pertama, blog CMS. JSON dipakai untuk edit ulang, HTML untuk publikasi, plain text untuk SEO, RSS, dan search. Kedua, knowledge base internal. Heading, list, code block, dan link biasanya cukup tanpa comment workflow. Ketiga, ecommerce. Rich text memperjelas benefit produk, tetapi HTML dan gambar harus dibatasi. Keempat, editor draft AI. HTML yang dibuat AI tetap untrusted input dan harus melewati sanitasi yang sama.

Pitfall yang sering terjadi

Jangan hanya menyimpan HTML jika konten penting untuk jangka panjang. Migrasi, Markdown export, daftar isi, dan collaboration akan lebih sulit. Jangan hanya percaya sanitasi di client karena API dapat dipanggil langsung. Jangan menunda test mobile; virtual keyboard, URL panjang, image, dan selection sering bermasalah di sana. Jangan menambah table, embed, mention, atau collaboration tanpa alasan produk yang jelas.

CTA monetisasi dan hasil uji

Rich text editor mempercepat siklus perbaikan konten: artikel SEO, halaman produk, materi training, help center, dan CTA dapat diperbarui lebih cepat. Untuk menjadikannya proses yang rapi, lihat training dan konsultasi Claude Code atau produk dan template Claude Code.

Saat diuji pada editor artikel kecil, hasil terbaik muncul ketika payload ditentukan lebih dulu: JSON, HTML yang disanitasi, plain text, dan validasi URL. Claude Code menghasilkan diff yang lebih kecil dan mudah direview. Sebelum publish, uji paste berformat, link javascript:, teks panjang, mobile width, save, reload, dan render publik.

#Claude Code #rich text editor #React #Tiptap #manajemen konten
Gratis

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.