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.

Tiptap Logo

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 useEditor hook initializes the editor instance
  • EditorContent renders 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

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>
  )
}

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