Tiptap
A headless, framework-agnostic rich text editor built on ProseMirror with extensive customization and a powerful extension system.
What is Tiptap?
Tiptap is a modern, headless rich text editor framework built on top of ProseMirror. It provides a powerful and flexible foundation for building rich text editing experiences in web applications without imposing any UI constraints.
Key Features
- Headless - Complete control over the UI and styling
- Framework Agnostic - Works with React, Vue, Svelte, or vanilla JavaScript
- Extensible - 100+ official extensions and easy custom extension creation
- TypeScript - Full type safety and excellent developer experience
- Collaborative - Real-time collaboration support out of the box
- Accessible - Built with accessibility in mind
- ProseMirror - Leverages the robust ProseMirror foundation
- Modular - Use only the features you need for smaller bundle sizes
Why Choose Tiptap?
Complete UI Control Unlike traditional editors, Tiptap is headless, meaning you build the UI yourself. This gives you complete freedom to match your application's design system.
Modern Architecture Built with modern JavaScript practices, Tiptap works seamlessly with contemporary frameworks and build tools.
Active Ecosystem With over 100 official extensions and an active community, Tiptap has a rich ecosystem of plugins and integrations.
Production Ready Used by companies like GitLab, Substack, and Axios, Tiptap is battle-tested in production environments.
Installation
Prerequisites
Make sure you have a Next.js project set up. If not, create one:
npx create-next-app@latest my-editor-app
cd my-editor-app
Install Tiptap Packages
Install the required Tiptap packages using yarn:
yarn add @tiptap/react @tiptap/pm @tiptap/starter-kit
Package Breakdown:
@tiptap/react- React integration for Tiptap@tiptap/pm- ProseMirror dependencies@tiptap/starter-kit- Bundle of commonly used extensions
Optional Extensions
Install additional extensions as needed:
# Additional formatting
yarn add @tiptap/extension-text-style @tiptap/extension-color
# Tables
yarn add @tiptap/extension-table @tiptap/extension-table-row @tiptap/extension-table-cell @tiptap/extension-table-header
# Images
yarn add @tiptap/extension-image
# Collaboration
yarn add @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor
# Typography helpers
yarn add @tiptap/extension-typography
# Character count
yarn add @tiptap/extension-character-count
Basic Usage
Creating Your First Editor
Create a new component file components/Tiptap.tsx:
'use client'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export default function Tiptap() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World! 🌍</p>',
editorProps: {
attributes: {
class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none',
},
},
})
return <EditorContent editor={editor} />
}
Important Notes:
- Use
'use client'directive for Next.js App Router - The
useEditorhook initializes the editor instance EditorContentrenders the actual editor- StarterKit includes commonly used extensions
Using the Editor
Now use the component in a page:
// app/editor/page.tsx
import Tiptap from '@/components/Tiptap'
export default function EditorPage() {
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-4">Rich Text Editor</h1>
<Tiptap />
</div>
)
}
Next.js Integration
Handling Server-Side Rendering
Tiptap needs to run in the browser. Use the immediatelyRender option to prevent SSR issues:
'use client'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export default function Tiptap() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
immediatelyRender: false, // Prevents SSR hydration issues
})
if (!editor) {
return null
}
return <EditorContent editor={editor} />
}
Dynamic Import Alternative
Alternatively, use Next.js dynamic imports:
// components/EditorWrapper.tsx
'use client'
import dynamic from 'next/dynamic'
const Editor = dynamic(() => import('./Tiptap'), {
ssr: false,
loading: () => <div className="animate-pulse bg-gray-200 h-64 rounded" />,
})
export default function EditorWrapper() {
return <Editor />
}
Building a Complete Editor
MenuBar Component
Create a toolbar with formatting controls:
'use client'
import { Editor } from '@tiptap/react'
import {
Bold,
Italic,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Undo,
Redo,
Minus,
} from 'lucide-react'
interface MenuBarProps {
editor: Editor | null
}
export default function MenuBar({ editor }: MenuBarProps) {
if (!editor) {
return null
}
return (
<div className="border border-gray-300 rounded-t-md p-2 flex flex-wrap gap-1">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('bold') ? 'bg-gray-200' : ''
}`}
>
<Bold className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('italic') ? 'bg-gray-200' : ''
}`}
>
<Italic className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('strike') ? 'bg-gray-200' : ''
}`}
>
<Strikethrough className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('code') ? 'bg-gray-200' : ''
}`}
>
<Code className="w-4 h-4" />
</button>
<div className="w-px h-6 bg-gray-300 my-auto mx-1" />
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('heading', { level: 1 }) ? 'bg-gray-200' : ''
}`}
>
<Heading1 className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('heading', { level: 2 }) ? 'bg-gray-200' : ''
}`}
>
<Heading2 className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('heading', { level: 3 }) ? 'bg-gray-200' : ''
}`}
>
<Heading3 className="w-4 h-4" />
</button>
<div className="w-px h-6 bg-gray-300 my-auto mx-1" />
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('bulletList') ? 'bg-gray-200' : ''
}`}
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('orderedList') ? 'bg-gray-200' : ''
}`}
>
<ListOrdered className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('blockquote') ? 'bg-gray-200' : ''
}`}
>
<Quote className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
className="p-2 rounded hover:bg-gray-100"
>
<Minus className="w-4 h-4" />
</button>
<div className="w-px h-6 bg-gray-300 my-auto mx-1" />
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
className="p-2 rounded hover:bg-gray-100 disabled:opacity-50"
>
<Undo className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
className="p-2 rounded hover:bg-gray-100 disabled:opacity-50"
>
<Redo className="w-4 h-4" />
</button>
</div>
)
}
Install Lucide Icons:
yarn add lucide-react
Complete Editor Component
Combine the editor and menu bar:
'use client'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import MenuBar from './MenuBar'
export default function TiptapEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Start typing...</p>',
editorProps: {
attributes: {
class:
'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none min-h-[300px] p-4 border border-gray-300 rounded-b-md',
},
},
immediatelyRender: false,
})
return (
<div className="max-w-4xl mx-auto">
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
)
}
Working with Extensions
StarterKit Contents
The StarterKit includes these extensions:
- Blockquote - Block quotes
- Bold - Bold text
- BulletList - Unordered lists
- Code - Inline code
- CodeBlock - Code blocks
- Document - Top-level document node
- Dropcursor - Visual cursor for drag and drop
- Gapcursor - Cursor for empty nodes
- HardBreak - Line breaks
- Heading - Headings (h1-h6)
- History - Undo/redo
- HorizontalRule - Horizontal dividers
- Italic - Italic text
- ListItem - List items
- OrderedList - Ordered lists
- Paragraph - Paragraphs
- Strike - Strikethrough text
- Text - Text nodes
Configuring StarterKit
You can configure or disable specific extensions:
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
const editor = useEditor({
extensions: [
StarterKit.configure({
// Disable some extensions
bulletList: false,
orderedList: false,
listItem: false,
// Configure history
history: {
depth: 10,
},
// Configure heading levels
heading: {
levels: [1, 2, 3],
},
}),
],
})
Adding Individual Extensions
Add specific extensions for more control:
import { useEditor, EditorContent } from '@tiptap/react'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import Bold from '@tiptap/extension-bold'
import Italic from '@tiptap/extension-italic'
import Underline from '@tiptap/extension-underline'
const editor = useEditor({
extensions: [
Document,
Paragraph,
Text,
Bold,
Italic,
Underline, // Not in StarterKit
],
})
Text Color Extension
Add text color support:
yarn add @tiptap/extension-text-style @tiptap/extension-color
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import TextStyle from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
const editor = useEditor({
extensions: [
StarterKit,
TextStyle,
Color,
],
})
// In your menu bar
<button
onClick={() => editor.chain().focus().setColor('#FF0000').run()}
className="p-2 rounded hover:bg-gray-100"
>
Red
</button>
Image Extension
Add image support:
yarn add @tiptap/extension-image
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
inline: true,
allowBase64: true,
}),
],
})
// Add image from URL
editor.chain().focus().setImage({ src: 'https://example.com/image.jpg' }).run()
// Add image with alt text
editor.chain().focus().setImage({
src: 'https://example.com/image.jpg',
alt: 'Description',
title: 'Image title',
}).run()
Image Upload Component:
'use client'
import { Image as ImageIcon } from 'lucide-react'
import { Editor } from '@tiptap/react'
interface ImageUploadProps {
editor: Editor | null
}
export default function ImageUpload({ editor }: ImageUploadProps) {
const addImage = () => {
const url = window.prompt('Enter image URL:')
if (url && editor) {
editor.chain().focus().setImage({ src: url }).run()
}
}
return (
<button
onClick={addImage}
className="p-2 rounded hover:bg-gray-100"
>
<ImageIcon className="w-4 h-4" />
</button>
)
}
Link Extension
Links are included in StarterKit, but here's how to customize them:
import Link from '@tiptap/extension-link'
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-blue-500 underline',
},
}),
],
})
// Add link
editor.chain().focus().setLink({ href: 'https://example.com' }).run()
// Remove link
editor.chain().focus().unsetLink().run()
Link Button Component:
import { Link as LinkIcon } from 'lucide-react'
function LinkButton({ editor }: { editor: Editor | null }) {
const setLink = () => {
const url = window.prompt('Enter URL:')
if (url && editor) {
editor.chain().focus().setLink({ href: url }).run()
}
}
const unsetLink = () => {
editor?.chain().focus().unsetLink().run()
}
if (!editor) return null
return (
<>
<button
onClick={setLink}
className={`p-2 rounded hover:bg-gray-100 ${
editor.isActive('link') ? 'bg-gray-200' : ''
}`}
>
<LinkIcon className="w-4 h-4" />
</button>
{editor.isActive('link') && (
<button
onClick={unsetLink}
className="p-2 rounded hover:bg-gray-100"
>
Unlink
</button>
)}
</>
)
}
Table Extension
Add table support:
yarn add @tiptap/extension-table @tiptap/extension-table-row @tiptap/extension-table-cell @tiptap/extension-table-header
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
],
})
// Insert table
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
// Add column
editor.chain().focus().addColumnAfter().run()
// Add row
editor.chain().focus().addRowAfter().run()
// Delete table
editor.chain().focus().deleteTable().run()
Content Management
Getting Content
Retrieve editor content in different formats:
// Get HTML
const html = editor.getHTML()
console.log(html) // '<p>Hello <strong>World</strong>!</p>'
// Get JSON
const json = editor.getJSON()
console.log(json)
// {
// type: 'doc',
// content: [
// {
// type: 'paragraph',
// content: [
// { type: 'text', text: 'Hello ' },
// { type: 'text', marks: [{ type: 'bold' }], text: 'World' },
// { type: 'text', text: '!' }
// ]
// }
// ]
// }
// Get plain text
const text = editor.getText()
console.log(text) // 'Hello World!'
// Get text with line breaks
const textWithBreaks = editor.getText({ blockSeparator: '\n\n' })
Setting Content
Set editor content programmatically:
// Set HTML content
editor.commands.setContent('<p>New content</p>')
// Set JSON content
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'New content',
},
],
},
],
})
// Clear content
editor.commands.clearContent()
Listening to Updates
React to content changes:
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Initial content</p>',
onUpdate: ({ editor }) => {
const html = editor.getHTML()
console.log('Content changed:', html)
},
})
Controlled Editor
Create a controlled editor with state:
'use client'
import { useState } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export default function ControlledEditor() {
const [content, setContent] = useState('<p>Initial content</p>')
const editor = useEditor({
extensions: [StarterKit],
content,
onUpdate: ({ editor }) => {
setContent(editor.getHTML())
},
immediatelyRender: false,
})
return (
<div>
<EditorContent editor={editor} />
<div className="mt-4">
<h3 className="font-bold">Preview:</h3>
<div dangerouslySetInnerHTML={{ __html: content }} />
</div>
</div>
)
}
Advanced Features
Character Count
Track document statistics:
yarn add @tiptap/extension-character-count
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import CharacterCount from '@tiptap/extension-character-count'
export default function EditorWithCount() {
const editor = useEditor({
extensions: [
StarterKit,
CharacterCount.configure({
limit: 1000,
}),
],
content: '<p>Type something...</p>',
immediatelyRender: false,
})
if (!editor) return null
return (
<div>
<EditorContent editor={editor} />
<div className="mt-2 text-sm text-gray-600">
{editor.storage.characterCount.characters()} / 1000 characters
<br />
{editor.storage.characterCount.words()} words
</div>
</div>
)
}
Placeholder
Add placeholder text:
yarn add @tiptap/extension-placeholder
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder: 'Write something amazing...',
emptyEditorClass: 'is-editor-empty',
}),
],
})
CSS for placeholder:
/* globals.css */
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: #adb5bd;
pointer-events: none;
height: 0;
float: left;
}
Focus Management
Control editor focus:
// Focus editor
editor.commands.focus()
// Focus at specific position
editor.commands.focus('start') // Beginning
editor.commands.focus('end') // End
editor.commands.focus(10) // Position 10
// Blur editor
editor.commands.blur()
// Check if focused
const isFocused = editor.isFocused
Typography Extension
Automatic smart quotes and replacements:
yarn add @tiptap/extension-typography
import Typography from '@tiptap/extension-typography'
const editor = useEditor({
extensions: [
StarterKit,
Typography,
],
})
// Automatic transformations:
// (c) → ©
// -> → →
// -- → –
// --- → —
// " → " " (smart quotes)
Task Lists
Add checkbox lists:
yarn add @tiptap/extension-task-list @tiptap/extension-task-item
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: false,
orderedList: false,
listItem: false,
}),
TaskList,
TaskItem.configure({
nested: true,
}),
],
})
// Toggle task list
editor.chain().focus().toggleTaskList().run()
CSS for task lists:
/* globals.css */
ul[data-type="taskList"] {
list-style: none;
padding: 0;
}
ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
}
ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
ul[data-type="taskList"] li > div {
flex: 1 1 auto;
}
Bubble Menu
Context menu that appears when selecting text:
yarn add @tiptap/extension-bubble-menu
'use client'
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Bold, Italic, Strikethrough } from 'lucide-react'
export default function EditorWithBubble() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Select some text to see the bubble menu!</p>',
immediatelyRender: false,
})
if (!editor) return null
return (
<>
<BubbleMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="bg-gray-900 text-white rounded-lg shadow-lg p-1 flex gap-1"
>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={`p-2 rounded ${
editor.isActive('bold') ? 'bg-gray-700' : ''
}`}
>
<Bold className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`p-2 rounded ${
editor.isActive('italic') ? 'bg-gray-700' : ''
}`}
>
<Italic className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={`p-2 rounded ${
editor.isActive('strike') ? 'bg-gray-700' : ''
}`}
>
<Strikethrough className="w-4 h-4" />
</button>
</BubbleMenu>
<EditorContent editor={editor} />
</>
)
}
Floating Menu
Menu that appears in empty lines:
import { useEditor, EditorContent, FloatingMenu } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export default function EditorWithFloating() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Click on an empty line to see the floating menu</p><p></p>',
immediatelyRender: false,
})
if (!editor) return null
return (
<>
<FloatingMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="bg-white border border-gray-300 rounded-lg shadow-lg p-2"
>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className="px-3 py-1 hover:bg-gray-100 rounded"
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className="px-3 py-1 hover:bg-gray-100 rounded"
>
List
</button>
</FloatingMenu>
<EditorContent editor={editor} />
</>
)
}
Styling the Editor
Tailwind Typography
Use the official Tailwind Typography plugin:
yarn add -D @tailwindcss/typography
Update tailwind.config.ts:
import type { Config } from 'tailwindcss'
const config: Config = {
plugins: [require('@tailwindcss/typography')],
}
export default config
Apply prose classes:
const editor = useEditor({
extensions: [StarterKit],
editorProps: {
attributes: {
class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl focus:outline-none',
},
},
})
Custom Styles
Add custom CSS for the editor:
/* globals.css */
/* Editor container */
.ProseMirror {
min-height: 200px;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
}
/* Focus styles */
.ProseMirror:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Paragraph spacing */
.ProseMirror p {
margin: 0.5rem 0;
}
/* Heading styles */
.ProseMirror h1 {
font-size: 2rem;
font-weight: bold;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.ProseMirror h2 {
font-size: 1.5rem;
font-weight: bold;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
/* Code block */
.ProseMirror pre {
background: #1f2937;
color: #f3f4f6;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-family: 'JetBrains Mono', monospace;
overflow-x: auto;
}
.ProseMirror code {
background: #f3f4f6;
color: #1f2937;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
/* Blockquote */
.ProseMirror blockquote {
border-left: 3px solid #3b82f6;
padding-left: 1rem;
font-style: italic;
color: #6b7280;
}
/* Lists */
.ProseMirror ul,
.ProseMirror ol {
padding-left: 1.5rem;
margin: 0.5rem 0;
}
/* Horizontal rule */
.ProseMirror hr {
border: none;
border-top: 2px solid #e5e7eb;
margin: 2rem 0;
}
Persistence and Autosave
Save to localStorage
'use client'
import { useEffect } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export default function PersistentEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: '',
onUpdate: ({ editor }) => {
const html = editor.getHTML()
localStorage.setItem('editor-content', html)
},
immediatelyRender: false,
})
useEffect(() => {
if (editor && !editor.isDestroyed) {
const saved = localStorage.getItem('editor-content')
if (saved) {
editor.commands.setContent(saved)
}
}
}, [editor])
return <EditorContent editor={editor} />
}
Autosave to API
'use client'
import { useEffect, useRef } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export default function AutosaveEditor({ documentId }: { documentId: string }) {
const saveTimeoutRef = useRef<NodeJS.Timeout>()
const editor = useEditor({
extensions: [StarterKit],
content: '',
onUpdate: ({ editor }) => {
// Clear previous timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
// Set new timeout for autosave
saveTimeoutRef.current = setTimeout(async () => {
const content = editor.getHTML()
await fetch(`/api/documents/${documentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
}, 1000) // Save 1 second after user stops typing
},
immediatelyRender: false,
})
useEffect(() => {
// Load initial content
async function loadContent() {
const response = await fetch(`/api/documents/${documentId}`)
const data = await response.json()
editor?.commands.setContent(data.content)
}
if (editor) {
loadContent()
}
// Cleanup
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [editor, documentId])
return <EditorContent editor={editor} />
}
Performance Optimization
Lazy Loading Extensions
Load extensions only when needed:
'use client'
import { useState } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export default function LazyEditor() {
const [tableEnabled, setTableEnabled] = useState(false)
const editor = useEditor({
extensions: [StarterKit],
immediatelyRender: false,
})
const enableTables = async () => {
const [Table, TableRow, TableCell, TableHeader] = await Promise.all([
import('@tiptap/extension-table').then((mod) => mod.default),
import('@tiptap/extension-table-row').then((mod) => mod.default),
import('@tiptap/extension-table-cell').then((mod) => mod.default),
import('@tiptap/extension-table-header').then((mod) => mod.default),
])
editor?.registerPlugin(
Table.configure({ resizable: true })
)
setTableEnabled(true)
}
return (
<div>
{!tableEnabled && (
<button onClick={enableTables}>Enable Tables</button>
)}
<EditorContent editor={editor} />
</div>
)
}
Debounced Updates
Optimize update handlers:
import { useCallback } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { debounce } from 'lodash'
export default function DebouncedEditor() {
const handleUpdate = useCallback(
debounce((content: string) => {
console.log('Saving:', content)
// Save to API
}, 500),
[]
)
const editor = useEditor({
extensions: [StarterKit],
onUpdate: ({ editor }) => {
handleUpdate(editor.getHTML())
},
immediatelyRender: false,
})
return <EditorContent editor={editor} />
}
Best Practices
1. Always Use 'use client' Directive
Tiptap requires browser APIs:
'use client'
import { useEditor, EditorContent } from '@tiptap/react'
2. Handle SSR Properly
Use immediatelyRender: false or dynamic imports:
const editor = useEditor({
extensions: [StarterKit],
immediatelyRender: false,
})
3. Clean Up Editor Instance
Destroy editor on unmount:
useEffect(() => {
return () => {
editor?.destroy()
}
}, [editor])
4. Use Modular Extensions
Only include extensions you need:
// ❌ Bad - includes everything
import StarterKit from '@tiptap/starter-kit'
// ✅ Good - only what you need
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import Bold from '@tiptap/extension-bold'
5. Sanitize User Content
Always sanitize HTML content:
yarn add isomorphic-dompurify
import DOMPurify from 'isomorphic-dompurify'
const sanitizedContent = DOMPurify.sanitize(editor.getHTML())
6. Implement Proper Error Handling
const editor = useEditor({
extensions: [StarterKit],
onError: ({ error }) => {
console.error('Editor error:', error)
// Show user-friendly error message
},
})
7. Use TypeScript
Leverage TypeScript for better development experience:
import { Editor } from '@tiptap/react'
interface EditorProps {
editor: Editor | null
onSave?: (content: string) => void
}
Troubleshooting
Hydration Errors
Problem: Editor content doesn't match between server and client.
Solution: Use immediatelyRender: false:
const editor = useEditor({
extensions: [StarterKit],
immediatelyRender: false,
})
Editor Not Focusing
Problem: Can't focus the editor programmatically.
Solution: Use the chain API:
editor?.chain().focus().run()
Content Not Updating
Problem: Content doesn't update when props change.
Solution: Use useEffect to update content:
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content)
}
}, [editor, content])
Extension Not Working
Problem: Extension features not available.
Solution: Check extension is registered:
// Check if extension is active
if (editor?.isActive('bold')) {
// Bold extension is working
}
// List all extensions
console.log(editor?.extensionManager.extensions)
Additional Resources
- Official Documentation - https://tiptap.dev/
- Examples - https://tiptap.dev/examples
- Extensions Directory - https://tiptap.dev/extensions
- GitHub Repository - https://github.com/ueberdosis/tiptap
- Discord Community - https://discord.gg/WtJ49jGshW
- ProseMirror Guide - https://prosemirror.net/docs/guide/