Back to Blog

React Rich Text Editor: Tiptap Setup, Custom Extensions, Toolbar, Image Upload, and Output Formats

Build a rich text editor in React with Tiptap. Covers StarterKit setup, custom toolbar with formatting controls, image upload extension with S3 presigned URLs, mention extension, markdown and JSON output, and collaborative editing with Yjs.

Viprasol Tech Team
May 29, 2027
13 min read

Tiptap is the best React rich text editor in 2027 β€” it's headless (you own all the UI), built on ProseMirror, has first-class TypeScript support, and has a thriving extension ecosystem. The only editors that compete (Lexical, Quill) have significantly worse DX for custom extensions.

This guide covers a complete production Tiptap setup: toolbar, image upload, mentions, and JSON/markdown output.

Installation

npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-placeholder \
  @tiptap/extension-image @tiptap/extension-mention @tiptap/extension-character-count \
  @tiptap/extension-link @tiptap/extension-underline @tiptap/extension-text-align

Basic Editor Setup

// components/editor/editor.tsx
"use client";

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import CharacterCount from "@tiptap/extension-character-count";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import Link from "@tiptap/extension-link";
import { EditorToolbar } from "./toolbar";
import { ImageExtension } from "./image-extension";
import { MentionExtension } from "./mention-extension";

interface EditorProps {
  content?:    string;         // JSON string or HTML
  onChange?:   (json: string) => void;
  placeholder?: string;
  maxChars?:   number;
  readOnly?:   boolean;
}

export function RichTextEditor({
  content,
  onChange,
  placeholder = "Start writing…",
  maxChars,
  readOnly = false,
}: EditorProps) {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        heading:      { levels: [1, 2, 3] },
        bulletList:   { keepMarks: true },
        orderedList:  { keepMarks: true },
        codeBlock:    { languageClassPrefix: "language-" },
        // Disable built-in history β€” use Y.js undo when collaborative
        history:      true,
      }),
      Placeholder.configure({ placeholder }),
      Underline,
      TextAlign.configure({ types: ["heading", "paragraph"] }),
      Link.configure({
        openOnClick: false,                    // Don't navigate in editor
        autolink:    true,
        HTMLAttributes: { rel: "noopener noreferrer nofollow", target: "_blank" },
      }),
      CharacterCount.configure({ limit: maxChars }),
      ImageExtension,
      MentionExtension,
    ],

    content:   content ? JSON.parse(content) : "",
    editable:  !readOnly,

    onUpdate({ editor }) {
      onChange?.(JSON.stringify(editor.getJSON()));
    },
  });

  if (!editor) return null;

  return (
    <div className="border border-gray-200 rounded-xl overflow-hidden">
      {!readOnly && <EditorToolbar editor={editor} />}

      <EditorContent
        editor={editor}
        className="prose prose-sm max-w-none px-4 py-3 min-h-[200px] focus-within:outline-none"
      />

      {maxChars && (
        <div className="px-4 py-2 border-t border-gray-100 flex justify-end">
          <span className={`text-xs ${
            editor.storage.characterCount.characters() >= maxChars
              ? "text-red-500"
              : "text-gray-400"
          }`}>
            {editor.storage.characterCount.characters()} / {maxChars}
          </span>
        </div>
      )}
    </div>
  );
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Toolbar Component

// components/editor/toolbar.tsx
"use client";

import type { Editor } from "@tiptap/react";
import {
  Bold, Italic, Underline as UnderlineIcon, Strikethrough,
  Code, Link as LinkIcon, Image, AlignLeft, AlignCenter, AlignRight,
  List, ListOrdered, Quote, Minus, Undo, Redo,
  Heading1, Heading2, Heading3,
} from "lucide-react";

interface ToolbarProps { editor: Editor }

interface ToolbarButtonProps {
  onClick:   () => void;
  active?:   boolean;
  disabled?: boolean;
  title:     string;
  children:  React.ReactNode;
}

function ToolbarButton({ onClick, active, disabled, title, children }: ToolbarButtonProps) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      title={title}
      className={`
        p-1.5 rounded-md text-sm transition-colors
        ${active
          ? "bg-gray-200 text-gray-900"
          : "text-gray-600 hover:bg-gray-100 hover:text-gray-900"}
        disabled:opacity-30 disabled:cursor-not-allowed
      `}
    >
      {children}
    </button>
  );
}

function Divider() {
  return <div className="w-px h-5 bg-gray-200 mx-1" />;
}

export function EditorToolbar({ editor }: ToolbarProps) {
  function setLink() {
    const url = window.prompt("URL:", editor.getAttributes("link").href ?? "https://");
    if (url === null) return;
    if (url === "") {
      editor.chain().focus().extendMarkRange("link").unsetLink().run();
    } else {
      editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
    }
  }

  return (
    <div className="flex flex-wrap items-center gap-0.5 px-3 py-2 border-b border-gray-100 bg-gray-50">
      {/* History */}
      <ToolbarButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo">
        <Undo className="w-4 h-4" />
      </ToolbarButton>
      <ToolbarButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo">
        <Redo className="w-4 h-4" />
      </ToolbarButton>

      <Divider />

      {/* Headings */}
      {([1, 2, 3] as const).map((level) => (
        <ToolbarButton
          key={level}
          onClick={() => editor.chain().focus().toggleHeading({ level }).run()}
          active={editor.isActive("heading", { level })}
          title={`Heading ${level}`}
        >
          <span className="text-xs font-bold">H{level}</span>
        </ToolbarButton>
      ))}

      <Divider />

      {/* Inline formatting */}
      <ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")} title="Bold">
        <Bold className="w-4 h-4" />
      </ToolbarButton>
      <ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")} title="Italic">
        <Italic className="w-4 h-4" />
      </ToolbarButton>
      <ToolbarButton onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive("underline")} title="Underline">
        <UnderlineIcon className="w-4 h-4" />
      </ToolbarButton>
      <ToolbarButton onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")} title="Strikethrough">
        <Strikethrough className="w-4 h-4" />
      </ToolbarButton>
      <ToolbarButton onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive("code")} title="Inline code">
        <Code className="w-4 h-4" />
      </ToolbarButton>

      <Divider />

      {/* Lists */}
      <ToolbarButton onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")} title="Bullet list">
        <List className="w-4 h-4" />
      </ToolbarButton>
      <ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")} title="Ordered list">
        <ListOrdered className="w-4 h-4" />
      </ToolbarButton>
      <ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")} title="Blockquote">
        <Quote className="w-4 h-4" />
      </ToolbarButton>

      <Divider />

      {/* Alignment */}
      {(["left", "center", "right"] as const).map((align) => (
        <ToolbarButton
          key={align}
          onClick={() => editor.chain().focus().setTextAlign(align).run()}
          active={editor.isActive({ textAlign: align })}
          title={`Align ${align}`}
        >
          {align === "left"   ? <AlignLeft className="w-4 h-4" />   :
           align === "center" ? <AlignCenter className="w-4 h-4" /> :
                                <AlignRight className="w-4 h-4" />}
        </ToolbarButton>
      ))}

      <Divider />

      {/* Link */}
      <ToolbarButton onClick={setLink} active={editor.isActive("link")} title="Link">
        <LinkIcon className="w-4 h-4" />
      </ToolbarButton>

      {/* Horizontal rule */}
      <ToolbarButton onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Horizontal rule">
        <Minus className="w-4 h-4" />
      </ToolbarButton>
    </div>
  );
}

Image Extension with S3 Upload

// components/editor/image-extension.ts
import Image from "@tiptap/extension-image";

export const ImageExtension = Image.extend({
  addAttributes() {
    return {
      ...this.parent?.(),
      width:  { default: null },
      height: { default: null },
      caption: { default: null },
    };
  },
}).configure({
  inline:    false,
  allowBase64: false,  // Don't allow base64 β€” always upload to S3
  HTMLAttributes: { class: "rounded-lg max-w-full h-auto" },
});

// Hook for image upload
export function useImageUpload(editor: ReturnType<typeof useEditor>) {
  async function uploadImage(file: File) {
    if (!editor) return;

    // Insert a placeholder while uploading
    editor.chain().focus().insertContent({
      type: "paragraph",
      content: [{ type: "text", text: "Uploading image…" }],
    }).run();

    try {
      // Get presigned URL
      const res = await fetch("/api/uploads/presigned", {
        method:  "POST",
        headers: { "Content-Type": "application/json" },
        body:    JSON.stringify({
          filename:    file.name,
          contentType: file.type,
          size:        file.size,
        }),
      });

      const { uploadUrl, key } = await res.json();

      // Upload to S3
      await fetch(uploadUrl, {
        method:  "PUT",
        headers: { "Content-Type": file.type },
        body:    file,
      });

      // Remove placeholder and insert image
      const imageUrl = `${process.env.NEXT_PUBLIC_CDN_URL}/${key}`;
      editor.chain().focus()
        .deleteRange({ from: editor.state.selection.from - 17, to: editor.state.selection.from })
        .setImage({ src: imageUrl, alt: file.name })
        .run();
    } catch (err) {
      console.error("Image upload failed:", err);
      editor.chain().focus().undo().run();
    }
  }

  return { uploadImage };
}

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Output Formats

// Get different output formats from the editor
function EditorOutputExample({ editor }: { editor: Editor }) {
  // 1. JSON (recommended for storage β€” lossless, re-renders perfectly)
  const json = JSON.stringify(editor.getJSON());

  // 2. HTML (for email rendering, legacy systems)
  const html = editor.getHTML();

  // 3. Plain text (for search index, notifications)
  const text = editor.getText();

  // 4. Markdown (requires @tiptap/extension-markdown)
  // import { Markdown } from "tiptap-markdown";
  // const markdown = editor.storage.markdown.getMarkdown();

  // Rendering stored JSON content:
  // <EditorContent editor={useEditor({ content: JSON.parse(stored), editable: false })} />

  // Or use generateHTML for server-side rendering:
  // import { generateHTML } from "@tiptap/html";
  // const html = generateHTML(JSON.parse(stored), [StarterKit, ...]);
}

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Basic Tiptap setup + toolbar1 dev1–2 days$400–800
+ Image upload (S3 presigned)1 dev1 day$300–600
+ Mentions + character count + link1 dev1 day$300–600
+ Collaborative editing (Yjs + Hocuspocus)1–2 devs1 week$3,000–6,000

See Also


Working With Viprasol

Rich text editors are deceptively hard to get right β€” image upload that works inside ProseMirror, output format that survives round-trips, toolbar state that reflects the actual cursor position, and link handling that doesn't navigate away mid-edit. Our team builds Tiptap integrations with custom image upload extensions (presigned S3 URLs, loading placeholder), link picker via window.prompt, character count limits, and JSON storage with generateHTML for server-side rendering.

What we deliver:

  • RichTextEditor with StarterKit, Placeholder, CharacterCount, Underline, TextAlign, Link, Image
  • EditorToolbar: undo/redo, H1/H2/H3, bold/italic/underline/strike/code, lists, alignment, link, HR
  • ImageExtension: custom attributes (width, height, caption), allowBase64: false
  • useImageUpload: presigned URL β†’ PUT β†’ CDN URL β†’ setImage in editor
  • Output: getJSON() for storage, getHTML() for email, getText() for search
  • generateHTML for SSR without mounting an editor

Talk to our team about your content editing requirements β†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.