Why I switched to Tiptap
Built content editors with everything over the years. Quill, Draft.js, Slate.js, even CKEditor. Every time, same story: either too limited, impossible to customize, or abandoned by maintainers.
Quill was easy but customization was a nightmare. Want a custom toolbar button? Fight the source. Draft.js gave me nightmares with immutable state. Slate was powerful but required building everything from scratch.
Then found Tiptap. Headless editor framework built on ProseMirror (battle-tested document model). Zero opinions about UI, but gives you powerful APIs to build whatever you want.
Built a custom markdown editor with slash commands, collaboration, and custom extensions in a day. Would've taken a week with anything else.
What makes Tiptap different
Headless means no default UI. You build the toolbar, menus, buttons - total control. Based on ProseMirror (same tech behind Notion, GitHub). React-specific package with hooks. Extensions system lets you add features modularly. Supports markdown out of the box, real-time collaboration, and everything in between.
Installation and setup
Get Tiptap running in minutes.
Install packages
# Core package
npm install @tiptap/react @tiptap/starter-kit
# Optional extensions
npm install @tiptap/extension-placeholder
npm install @tiptap/extension-link
npm install @tiptap/extension-image
npm install @tiptap/extension-code-block-lowlight
npm install lowlight # for syntax highlighting
Basic editor component
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
function Editor() {
const editor = useEditor({
extensions: [StarterKit],
content: 'Hello World!
',
})
if (!editor) {
return null
}
return (
)
}
Add basic styling
/* Basic editor styles */
.ProseMirror {
outline: none;
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
min-height: 200px;
}
.ProseMirror p {
margin-bottom: 0.5rem;
}
.ProseMirror h1 {
font-size: 1.5rem;
font-weight: bold;
margin: 1rem 0 0.5rem;
}
.ProseMirror ul,
.ProseMirror ol {
padding-left: 1.5rem;
margin-bottom: 0.5rem;
}
.ProseMirror code {
background: #f3f4f6;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-family: monospace;
}
.ProseMirror pre {
background: #1f2937;
color: #f9fafb;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
}
.ProseMirror blockquote {
border-left: 4px solid #8b5cf6;
padding-left: 1rem;
font-style: italic;
}
Building a toolbar
Tiptap is headless, so you build the UI. Here's a practical toolbar.
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import {
FaBold,
FaItalic,
FaStrikethrough,
FaCode,
FaHeading,
FaListUl,
FaListOl,
FaQuoteLeft,
FaUndo,
FaRedo,
} from 'react-icons/fa'
function RichTextEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: 'Start writing...
',
})
if (!editor) return null
const ToolbarButton = ({
onClick,
isActive,
children,
}: {
onClick: () => void
isActive?: boolean
children: React.ReactNode
}) => (
)
return (
{/* Toolbar */}
editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
>
editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
>
editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
>
editor.chain().focus().toggleCode().run()}
isActive={editor.isActive('code')}
>
editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
>
1
editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
>
2
editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
>
editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
>
editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
>
editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
>
editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
>
{/* Editor */}
{/* Character count */}
{editor.storage.characterCount?.characters() || 0} characters
)
}
Essential extensions
Extensions make Tiptap powerful. Here's what I use in production.
1. Placeholder
Shows placeholder text when editor is empty.
import Placeholder from '@tiptap/extension-placeholder'
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder: 'Write something awesome...',
}),
],
})
CSS to style placeholder: .ProseMirror p.is-empty::before { content: attr(data-placeholder); color: #9ca3af; }
2. Link
Add and edit links with auto-detection.
import Link from '@tiptap/extension-link'
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-blue-600 underline',
},
}),
],
})
// Add link programmatically
editor.chain().focus().setLink({ href: 'https://example.com' }).run()
// Remove link
editor.chain().focus().unsetLink().run()
3. Image
Insert images with drag-and-drop support.
import Image from '@tiptap/extension-image'
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
inline: true,
allowBase64: true,
HTMLAttributes: {
class: 'rounded-lg max-w-full',
},
}),
],
})
// Add image
editor.chain().focus().setImage({ src: 'https://example.com/image.jpg' }).run()
4. Code block with syntax highlighting
Beautiful code blocks with language support.
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import { lowlight } from 'lowlight'
import ts from 'lowlight/lib/languages/typescript'
import python from 'lowlight/lib/languages/python'
lowlight.registerLanguage('typescript', ts)
lowlight.registerLanguage('python', python)
const editor = useEditor({
extensions: [
StarterKit,
CodeBlockLowlight.configure({
lowlight,
defaultLanguage: 'typescript',
}),
],
})
5. Character count
Track characters, words, and sentences.
import CharacterCount from '@tiptap/extension-character-count'
const editor = useEditor({
extensions: [
StarterKit,
CharacterCount,
],
})
// Access counts
editor.storage.characterCount.characters()
editor.storage.characterCount.words()
6. Task list
Interactive checkboxes for todo lists.
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
const editor = useEditor({
extensions: [
StarterKit,
TaskList,
TaskItem.configure({
nested: true,
}),
],
})
7. Mention
@mention users like in Slack or Notion.
import Mention from '@tiptap/extension-mention'
import { VueRenderer } from '@tiptap/vue-2'
import MentionList from './MentionList.vue'
const editor = useEditor({
extensions: [
StarterKit,
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: {
items: ({ query }) => {
return users.filter(user =>
user.name.toLowerCase().includes(query.toLowerCase())
)
},
render: () => {
let component
return {
onStart: (props) => {
component = new VueRenderer(MentionList, {
props,
editor,
})
},
onUpdate(props) {
component.updateProps(props)
},
onKeyPress() {
component.updateProps({ ...props })
},
onExit() {
component.destroy()
},
}
},
},
}),
],
})
Building custom extensions
Real power: build your own extensions.
Custom mark extension (highlight)
import { Mark, mergeAttributes } from '@tiptap/core'
const Highlight = Mark.create({
name: 'highlight',
addOptions() {
return {
colors: ['#fef08a', '#fca5a5', '#86efac', '#93c5fd'],
}
},
addAttributes() {
return {
color: {
default: null,
parseHTML: element => element.getAttribute('data-color'),
renderHTML: attributes => {
if (!attributes.color) {
return {}
}
return {
'data-color': attributes.color,
style: `background-color: ${attributes.color}`,
}
},
},
}
},
parseHTML() {
return [
{
tag: 'mark',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['mark', mergeAttributes(HTMLAttributes), 0]
},
addCommands() {
return {
setHighlight:
attributes =>
({ commands }) => {
return commands.setMark(this.name, attributes)
},
toggleHighlight:
attributes =>
({ commands }) => {
return commands.toggleMark(this.name, attributes)
},
unsetHighlight:
() =>
({ commands }) => {
return commands.unsetMark(this.name)
},
}
},
})
export default Highlight
Use custom extension
import Highlight from './extensions/highlight'
const editor = useEditor({
extensions: [
StarterKit,
Highlight,
],
})
// Toggle highlight
editor.chain().focus().toggleHighlight({ color: '#fef08a' }).run()
// Check if highlight is active
editor.isActive('highlight')
Markdown support
Tiptap has excellent markdown support out of the box.
Enable markdown
import StarterKit from '@tiptap/starter-kit'
import Markdown from '@tiptap/markdown'
const editor = useEditor({
extensions: [
StarterKit,
Markdown.configure({
html: false,
transformPastedText: true,
}),
],
})
// Get markdown
const markdown = editor.storage.markdown.getMarkdown()
// Set markdown
editor.commands.setContent('## Heading\n\nParagraph text')
Markdown shortcuts
# Type at start of line:
# → Heading 1
## → Heading 2
### → Heading 3
- → Bullet list
* → Bullet list
1. → Ordered list
[] → Checkbox
> → Blockquote
``` → Code block
Real-time collaboration
Like Google Docs, powered by WebSockets.
Setup collaboration
import { HocuspocusProvider } from '@hocuspocus/provider'
import { Collaboration, CollaborationCursor } from '@tiptap/extension-collaboration'
const provider = new HocuspocusProvider({
url: 'wss://your-server.com',
name: 'my-document',
})
const editor = useEditor({
extensions: [
StarterKit,
Collaboration.configure({
document: provider.document,
}),
CollaborationCursor.configure({
provider: provider,
user: {
name: 'John Doe',
color: '#8b5cf6',
},
}),
],
})
Style collaboration cursors
/* Collaboration cursor */
.collaboration-cursor__caret {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid;
word-break: normal;
pointer-events: none;
}
.collaboration-cursor__label {
position: absolute;
top: -1.4em;
left: -1px;
font-size: 12px;
padding: 0.2rem 0.4rem;
border-radius: 4px;
color: white;
white-space: nowrap;
}
Tiptap vs other editors
Quick comparison of rich text editors.
| Feature | Tiptap | Quill | Slate |
|---|---|---|---|
| Headless | Yes | No | Yes |
| React support | Excellent | Basic | Good |
| Extensions | Easy to build | Limited | Manual |
| Markdown | Built-in | Plugin | Manual |
| Collaboration | Built-in | Complex | Manual |
| Learning curve | Moderate | Easy | Steep |
Common issues and fixes
Stuff that breaks and how to fix it.
Issue: Editor not rendering
Fix: Ensure useEditor is called in a React component. Check that EditorContent receives the editor instance. Verify if (!editor) return null check exists.
Issue: Toolbar buttons not working
Fix: Ensure editor.chain().focus().toggleBold().run() includes .focus(). Without focus, commands may not execute. Check console for errors.
Issue: Styling not applied
Fix: Tiptap uses .ProseMirror class. Ensure CSS targets this class, not editor. Check specificity. Some elements need explicit styling (code blocks, lists).
Issue: Extension not working
Fix: Verify extension is imported and added to extensions array. Check for conflicts between extensions (two bold marks, for example). Read extension docs carefully.
Issue: Markdown import/export broken
Fix: Ensure markdown extension is configured correctly. Check HTMLAttributes don't conflict. Test with simple markdown first.
Best practices from production use
Lessons learned from real projects.
1. Start with StarterKit
Includes most common extensions. Build from there, not from scratch.
2. Use React-specific packages
Tiptap has dedicated React packages. Better than generic wrappers.
3. Optimize performance
// Debounce auto-save
import { debounce } from 'lodash'
const saveContent = debounce((content) => {
localStorage.setItem('draft', content)
}, 1000)
editor.on('update', ({ editor }) => {
saveContent(editor.getHTML())
})
4. Sanitize HTML
import DOMPurify from 'dompurify'
const cleanHTML = DOMPurify.sanitize(editor.getHTML())
saveToDatabase(cleanHTML)
5. Handle mobile carefully
Test on real devices. Mobile keyboards and text selection behave differently. Add mobile-specific styles.
6. Provide feedback
Show loading states during saves. Display character counts. Highlight active toolbar buttons. Good UX matters.
Bottom line
Tiptap isn't for every use case. If you need a simple WYSIWYG editor with zero customization, Quill might be better. If you want something that just works out of the box with zero effort, consider CKEditor.
But if you need a custom editor that fits your exact requirements, Tiptap is unbeatable. Built a Notion-like editor with slash commands in a day. Created a markdown editor with collaboration in an afternoon. Total control over the UI, powerful API, excellent React support.
Best part: based on ProseMirror, so it's battle-tested. Companies like Notion, GitHub, Linear use similar tech. You're not building on someone's experimental framework.
Give it a shot on your next content editor. Start with StarterKit, add extensions as needed. Build a custom toolbar. See how fast it comes together.