Tiptap: Finally Built a Rich Text Editor That Doesn't Suck

Tried every rich text editor out there. Tiptap is different - headless, customizable, React-friendly. Built a custom editor in a day. Here's my complete guide.

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.