Rich Text Editor in React: Tiptap v3 Setup and Custom Extensions
Build a production-ready rich text editor in React with Tiptap v3 — full setup, toolbar wiring, and custom extensions explained step by step.
Why Tiptap in 2026
There are roughly a dozen React WYSIWYG editors floating around npm. Most of them are either abandoned, opaque about their ProseMirror internals, or ship a 400 KB bundle the second you install them. Tiptap v3 is different — it's built directly on top of ProseMirror but wraps the complexity in an extension model that's actually approachable without reading the entire ProseMirror guide first.
Honestly, the biggest reason to pick Tiptap over something like Quill or Draft.js in 2026 is the extension system. You write a node or mark as a plain TypeScript class, register it, and you're done. No patching internals. No fighting with conflicting plugins. The core team also finally stabilised the v3 API in early 2026 and dropped the legacy @tiptap/starter-kit peer-dep mess that bit people on v2.
Worth noting: Tiptap is MIT licensed for the core. The collaboration and AI features are behind a paid cloud plan, but you don't need any of that for a standard content editor. Everything in this article is 100% free.
Quick aside: if you're pairing the editor with a styled component system like Empire UI, you'll want to keep Tiptap's output as clean HTML or JSON — both are supported natively and map directly to React components.
Installation and Project Setup
Start with a fresh Next.js or Vite + React project. Tiptap v3 requires React 18 or later and works fine with the App Router. Install the packages you actually need — don't just grab starter-kit blindly, it pulls in extensions you may never use.
npm install @tiptap/react @tiptap/pm @tiptap/core
# Core extensions you'll almost certainly want
npm install @tiptap/extension-document @tiptap/extension-paragraph @tiptap/extension-text
npm install @tiptap/extension-bold @tiptap/extension-italic @tiptap/extension-underline
npm install @tiptap/extension-heading @tiptap/extension-bullet-list @tiptap/extension-ordered-list
npm install @tiptap/extension-link @tiptap/extension-image @tiptap/extension-code-blockIf you don't want to cherry-pick, @tiptap/starter-kit in v3 is finally a sane bundle — it includes the common extensions without dragging in collaboration or AI dependencies. That said, for production apps, explicit imports give you better tree-shaking and fewer surprises when the starter-kit updates.
One more thing — Tiptap ships zero default styles. That's by design. Your editor will look like plain text until you add CSS. Either write your own .tiptap styles or pull in @tiptap/extension-typography which at least handles some punctuation smartness while you build out the visual layer.
Wiring Up the Editor Component
The useEditor hook is where everything starts. You pass it a config object, get back an editor instance, and feed that instance to <EditorContent />. Simple on the surface, but the config is where you'll spend most of your time.
'use client'; // Next.js App Router needs this
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
interface RichEditorProps {
content?: string;
onChange?: (html: string) => void;
}
export function RichEditor({ content = '', onChange }: RichEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
}),
],
content,
onUpdate({ editor }) {
onChange?.(editor.getHTML());
},
editorProps: {
attributes: {
class: 'tiptap-editor prose max-w-none focus:outline-none',
},
},
});
return (
<div className="border border-zinc-200 rounded-xl p-4">
<EditorContent editor={editor} />
</div>
);
}The onUpdate callback fires on every keystroke, which is fine for small documents. For larger content or collaborative setups, debounce it — 300ms is usually enough to prevent performance issues without feeling sluggish to the user.
In practice, editor.getHTML() and editor.getJSON() cover most persistence needs. JSON is preferable if you're storing in a database and re-rendering in React — it's easier to transform and doesn't carry XSS risks. HTML output is useful when you need to send content to a CMS or email system that expects markup.
If you're using the App Router in Next.js 14+, the 'use client' directive is non-negotiable. useEditor is stateful and runs in the browser. You'll hit a hydration error immediately if you forget it.
Building a Toolbar
Tiptap's editor instance exposes a chain() API for commanding the editor. It's fluent — you chain commands, then call .run(). This makes toolbar buttons dead simple to wire up without external state.
import { Editor } from '@tiptap/core';
interface ToolbarProps { editor: Editor | null; }
export function Toolbar({ editor }: ToolbarProps) {
if (!editor) return null;
const btn = (label: string, action: () => void, isActive: boolean) => (
<button
key={label}
onMouseDown={(e) => { e.preventDefault(); action(); }}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-zinc-900 text-white'
: 'text-zinc-600 hover:bg-zinc-100'
}`}
>
{label}
</button>
);
return (
<div className="flex flex-wrap gap-1 border-b border-zinc-200 pb-2 mb-3">
{btn('B', () => editor.chain().focus().toggleBold().run(), editor.isActive('bold'))}
{btn('I', () => editor.chain().focus().toggleItalic().run(), editor.isActive('italic'))}
{btn('U', () => editor.chain().focus().toggleUnderline().run(), editor.isActive('underline'))}
{btn('H1', () => editor.chain().focus().toggleHeading({ level: 1 }).run(), editor.isActive('heading', { level: 1 }))}
{btn('H2', () => editor.chain().focus().toggleHeading({ level: 2 }).run(), editor.isActive('heading', { level: 2 }))}
{btn('List', () => editor.chain().focus().toggleBulletList().run(), editor.isActive('bulletList'))}
{btn('Code', () => editor.chain().focus().toggleCodeBlock().run(), editor.isActive('codeBlock'))}
</div>
);
}Notice the onMouseDown with preventDefault() instead of onClick. This is the single most common Tiptap gotcha — if you use onClick, the editor loses focus before the command runs, so focus() in the chain does nothing. Always onMouseDown + preventDefault() on toolbar buttons.
The isActive() check gives you the active state for toggling button styles. It accepts a node/mark name and optional attributes — so editor.isActive('heading', { level: 2 }) only returns true when the cursor is inside an <h2>, not any heading.
Worth noting: for production toolbars you'd usually reach for icon libraries like Lucide or Radix Icons instead of text labels. The structure above is intentionally minimal so you can see the logic without icon library noise.
Writing Custom Extensions
This is where Tiptap gets genuinely powerful. Say you need a custom callout block — a styled <div> with a type attribute (info, warning, danger) that doesn't exist in any starter kit. You write it as a Node extension.
import { Node, mergeAttributes } from '@tiptap/core';
export const Callout = Node.create({
name: 'callout',
group: 'block',
content: 'block+',
addAttributes() {
return {
type: {
default: 'info',
parseHTML: (el) => el.getAttribute('data-type'),
renderHTML: (attrs) => ({ 'data-type': attrs.type }),
},
};
},
parseHTML() {
return [{ tag: 'div[data-callout]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, { 'data-callout': '' }),
0, // 0 means "render children here"
];
},
addCommands() {
return {
setCallout:
(attrs) =>
({ commands }) =>
commands.setNode(this.name, attrs),
};
},
});The content: 'block+' line tells ProseMirror the callout can hold one or more block nodes inside it — paragraphs, headings, lists, whatever. The group: 'block' line makes it available anywhere a block element is allowed. These two lines define the schema, and getting them wrong is usually why custom nodes behave strangely.
Once you've written the extension, register it in your useEditor call: extensions: [StarterKit, Callout]. Then trigger it from a toolbar button with editor.chain().focus().setCallout({ type: 'warning' }).run(). Clean, predictable, no magic.
Look, you can build remarkably complex nodes this way — custom video embeds, interactive widgets, mention nodes with async user lookups. The extension model scales well. Where it gets tricky is with React node views (rendering actual React components inside ProseMirror nodes), which requires addNodeView() and ReactNodeViewRenderer — a topic worth its own article.
In practice, keep custom extensions in a /extensions folder alongside your editor component, one file per extension. They get large fast, and mixing them into your component file turns into a mess around the 100-line mark.
Styling the Editor Output
Tiptap outputs clean, semantic HTML. But since it ships with zero styles, a heading inside the editor won't look like a heading unless you tell it to. The two standard approaches are Tailwind Typography or hand-rolled prose styles.
/* globals.css — minimal editor styles without Tailwind Typography */
.tiptap-editor h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.75rem; }
.tiptap-editor h2 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; }
.tiptap-editor p { margin-bottom: 0.75rem; line-height: 1.65; }
.tiptap-editor ul { list-style: disc; padding-left: 1.5rem; margin-bottom: 0.75rem; }
.tiptap-editor ol { list-style: decimal; padding-left: 1.5rem; margin-bottom: 0.75rem; }
.tiptap-editor strong { font-weight: 600; }
.tiptap-editor em { font-style: italic; }
.tiptap-editor code { font-family: monospace; background: #f4f4f5; padding: 2px 6px; border-radius: 4px; font-size: 0.875em; }
.tiptap-editor pre { background: #18181b; color: #e4e4e7; padding: 1rem; border-radius: 8px; overflow-x: auto; margin-bottom: 1rem; }
.tiptap-editor a { color: #6366f1; text-decoration: underline; }
/* Selection state */
.tiptap-editor .ProseMirror-selectednode { outline: 2px solid #6366f1; outline-offset: 2px; }If you're already using Tailwind, @tailwindcss/typography with prose class on the editor container is the 20-second solution. The prose class is battle-tested and handles the annoying edge cases like nested lists and blockquote spacing that always break hand-rolled styles.
One more thing — style the placeholder too. Tiptap uses a CSS pseudo-element for it, not a real DOM attribute, so you need this: .tiptap-editor p.is-editor-empty:first-child::before { content: attr(data-placeholder); color: #a1a1aa; pointer-events: none; }. Add it alongside the Placeholder extension from @tiptap/extension-placeholder and you're set.
For dark mode, scope your editor styles under a .dark class or use CSS custom properties. The 6px padding on inline code and 8px border-radius on code blocks feel balanced at most font sizes — adjust from there based on your design system. Speaking of design systems, the box shadow generator on Empire UI is handy for dialling in the editor container shadow without guesswork.
Common Gotchas and Performance Notes
SSR. It always comes back to SSR. Tiptap accesses document during init, which breaks server rendering. In Next.js App Router, 'use client' handles this. In Pages Router or anywhere you're doing SSR, dynamic import with ssr: false is the answer: const RichEditor = dynamic(() => import('./RichEditor'), { ssr: false }). Don't skip this step.
The editor instance from useEditor is nullable — it's null on the first render tick. Every piece of code that touches editor needs a null guard. This catches most people on the toolbar (if (!editor) return null) and on form submission logic that tries to call editor.getHTML() before mount.
Performance gets interesting with long documents. ProseMirror re-renders the entire document tree on every transaction by default. For documents over roughly 5,000 words, you'll start to notice jank. The @tiptap/extension-collaboration split-block approach helps, but for purely local editors, the simpler fix is to debounce your onUpdate callback to 300–500ms and avoid storing the full HTML in React state on every keystroke.
If you're building a multi-tenant app where users paste from Word or Google Docs, the @tiptap/extension-paste-rules and HTML sanitisation become important. Users will paste <script> tags, inline styles with 48px Times New Roman, and all manner of cursed markup. Run DOMPurify on any HTML you receive from the editor before storing it. Non-negotiable. And if you're displaying editor output alongside components from a component library like Empire UI, dirty HTML will break layout in ways that are genuinely hard to debug.
Lastly, test keyboard navigation. Tiptap is solid on accessibility basics, but custom node views with React components won't automatically get keyboard support — you have to wire it yourself. Run through the core flows with a keyboard before shipping: bold, lists, links, heading levels. Your users who rely on keyboards will thank you.
FAQ
Yes. The v3 API stabilised in early 2026 and several large SaaS products ship it in production. Stick to the documented extension API and you won't hit breaking changes.
Yes, but you need the 'use client' directive on any component that calls useEditor. Tiptap is browser-only — it reads document on init and can't run on the server.
Call editor.getJSON() in your onUpdate callback and store the result as JSON. It's safer than HTML for storage and makes re-rendering trivial — just pass it back to the content prop.
A Node is a block or inline element that holds content — think paragraphs, headings, images. A Mark is an annotation on text — bold, italic, a link. Nodes can nest; Marks can overlap on the same text range.