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.
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
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic Tiptap setup + toolbar | 1 dev | 1β2 days | $400β800 |
| + Image upload (S3 presigned) | 1 dev | 1 day | $300β600 |
| + Mentions + character count + link | 1 dev | 1 day | $300β600 |
| + Collaborative editing (Yjs + Hocuspocus) | 1β2 devs | 1 week | $3,000β6,000 |
See Also
- Next.js File Uploads to S3
- React Drag and Drop File Upload
- SaaS Onboarding Flow
- React Form Builder
- Next.js Draft Mode
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:
RichTextEditorwith StarterKit, Placeholder, CharacterCount, Underline, TextAlign, Link, ImageEditorToolbar: undo/redo, H1/H2/H3, bold/italic/underline/strike/code, lists, alignment, link, HRImageExtension: custom attributes (width, height, caption),allowBase64: falseuseImageUpload: presigned URL β PUT β CDN URL βsetImagein editor- Output:
getJSON()for storage,getHTML()for email,getText()for search generateHTMLfor SSR without mounting an editor
Talk to our team about your content editing requirements β
Or explore our web development services.
About the Author
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.
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
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.