Skip to content

Fix: Tiptap Not Working — Editor Not Rendering, Extensions Missing, or Content Not Saving

FixDevs ·

Quick Answer

How to fix Tiptap editor issues — useEditor setup in React, StarterKit configuration, custom nodes and marks, SSR with Next.js, collaborative editing, and content serialization.

The Problem

The editor renders an empty div and no toolbar appears:

import { useEditor, EditorContent } from '@tiptap/react';

function Editor() {
  const editor = useEditor({
    extensions: [],
    content: '<p>Hello world</p>',
  });

  return <EditorContent editor={editor} />;
}
// Blank div — no editor, no content

Or an extension throws at runtime:

Error: Extension "Bold" is missing its dependency "Marks".

Or content saves as an empty object even though text is visible in the editor:

const json = editor.getJSON();
// { type: 'doc', content: [] }  — empty despite visible text

Or Tiptap crashes on Next.js with a hydration error:

Error: Hydration failed because the initial UI does not match what was rendered on the server.

Why This Happens

Tiptap is a headless, extension-based editor built on top of ProseMirror. Its architecture has a few sharp edges:

  • Extensions are not included by defaultuseEditor({ extensions: [] }) gives you a completely bare ProseMirror editor. Even paragraphs don’t work without the Paragraph extension. StarterKit bundles the most common extensions, but any feature beyond the basics must be explicitly imported and registered.
  • Extensions have dependency requirementsBold requires Marks, Link requires Bold in some configurations, and custom nodes may depend on other extensions being registered first. Missing a dependency throws at runtime.
  • Content is read at mount time — if you pass content to useEditor and then update it later with editor.commands.setContent(), the initial render still determines the ProseMirror document structure. An extension mismatch (e.g., content references a node type that isn’t registered) silently drops that content.
  • Tiptap’s editor is a browser-only construct — it uses document and DOM APIs that don’t exist in Node.js. Server-side rendering with Next.js App Router requires either rendering the editor only on the client or using dynamic imports with ssr: false.

Fix 1: Set Up useEditor Correctly with StarterKit

StarterKit is the quickest way to get a fully functional editor:

npm install @tiptap/react @tiptap/pm @tiptap/starter-kit
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';

function RichTextEditor() {
  const editor = useEditor({
    extensions: [
      StarterKit,
      // StarterKit includes: Blockquote, Bold, BulletList, Code, CodeBlock,
      // Document, Dropcursor, Gapcursor, HardBreak, Heading, History,
      // HorizontalRule, Italic, ListItem, OrderedList, Paragraph,
      // Strike, Text
    ],
    content: '<p>Start typing here...</p>',
    // Called every time the content changes
    onUpdate: ({ editor }) => {
      const json = editor.getJSON();
      const html = editor.getHTML();
      console.log(json);
    },
  });

  // editor is null during SSR and on first render — always guard
  if (!editor) return null;

  return (
    <div>
      {/* Toolbar */}
      <div>
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'active' : ''}
        >
          Bold
        </button>
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'active' : ''}
        >
          Italic
        </button>
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'active' : ''}
        >
          H2
        </button>
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'active' : ''}
        >
          List
        </button>
      </div>

      {/* Editor area */}
      <EditorContent editor={editor} />
    </div>
  );
}

Disable specific StarterKit extensions to avoid conflicts:

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      // Disable extensions you'll replace with custom ones
      codeBlock: false,      // Replace with CodeBlockLowlight
      history: false,        // Disable if using collaborative editing (y-prosemirror handles it)
      heading: {
        levels: [1, 2, 3],  // Only allow H1, H2, H3
      },
      bold: {
        HTMLAttributes: {
          class: 'font-bold',  // Add custom CSS class
        },
      },
    }),
  ],
});

Fix 2: Add Extensions Beyond StarterKit

Extensions must be installed separately:

npm install @tiptap/extension-link @tiptap/extension-image @tiptap/extension-placeholder @tiptap/extension-character-count @tiptap/extension-color @tiptap/extension-text-style
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
import { Color } from '@tiptap/extension-color';
import TextStyle from '@tiptap/extension-text-style';

const editor = useEditor({
  extensions: [
    StarterKit,
    // Link extension — requires TextStyle for styling
    Link.configure({
      openOnClick: false,        // Don't follow links in editor
      autolink: true,            // Auto-detect URLs as you type
      HTMLAttributes: {
        rel: 'noopener noreferrer',
        class: 'text-blue-500 underline',
      },
    }),
    // Image extension
    Image.configure({
      inline: false,             // Block-level images
      allowBase64: true,         // Allow base64 data URLs
      HTMLAttributes: {
        class: 'max-w-full rounded',
      },
    }),
    // Placeholder text
    Placeholder.configure({
      placeholder: 'Start writing...',
      // Different placeholder per node type
      placeholder: ({ node }) => {
        if (node.type.name === 'heading') return 'What's the title?';
        return 'Start writing...';
      },
    }),
    // Character and word count
    CharacterCount.configure({
      limit: 10000,  // Hard limit
    }),
    // Text color — requires TextStyle
    TextStyle,
    Color,
  ],
  content: initialContent,
});

// Adding a link
editor.chain().focus().setLink({ href: 'https://example.com' }).run();

// Removing a link
editor.chain().focus().unsetLink().run();

// Inserting an image
editor.chain().focus().setImage({ src: '/image.jpg', alt: 'My image' }).run();

// Getting character count
const { characters, words } = editor.storage.characterCount;

Fix 3: Fix SSR and Next.js Hydration Errors

Tiptap uses browser-only APIs. Two approaches work:

Option 1 — Dynamic import with ssr: false (simplest):

// components/RichTextEditor.tsx — the actual editor component
'use client';

import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';

export function RichTextEditor({ content, onChange }: {
  content: string;
  onChange: (html: string) => void;
}) {
  const editor = useEditor({
    extensions: [StarterKit],
    content,
    onUpdate: ({ editor }) => onChange(editor.getHTML()),
  });

  if (!editor) return null;
  return <EditorContent editor={editor} />;
}
// app/page.tsx or pages/editor.tsx — Next.js page
import dynamic from 'next/dynamic';

// Load editor only on client — no SSR
const RichTextEditor = dynamic(
  () => import('@/components/RichTextEditor').then(m => m.RichTextEditor),
  { ssr: false, loading: () => <div>Loading editor...</div> }
);

export default function Page() {
  return <RichTextEditor content="<p>Hello</p>" onChange={console.log} />;
}

Option 2 — useEffect with isMounted flag:

'use client';

import { useState, useEffect } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';

export function Editor() {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello</p>',
    // Disable immediate rendering — wait for mount
    immediatelyRender: false,
  });

  if (!isMounted || !editor) return null;
  return <EditorContent editor={editor} />;
}

Option 3 — immediatelyRender: false (Tiptap 2.4+):

const editor = useEditor({
  extensions: [StarterKit],
  content: initialContent,
  immediatelyRender: false,  // Prevents SSR/CSR mismatch
  shouldRerenderOnTransaction: false,  // Performance optimization
});

Fix 4: Update Content Programmatically

Passing a new content prop doesn’t update the editor — use commands:

import { useEditor, EditorContent } from '@tiptap/react';
import { useEffect } from 'react';

function Editor({ content }: { content: string }) {
  const editor = useEditor({
    extensions: [StarterKit],
    content,
  });

  // WRONG — changing the content prop doesn't update the editor
  // useEffect(() => { /* nothing */ }, [content]);

  // CORRECT — use setContent command when prop changes
  useEffect(() => {
    if (!editor) return;
    if (editor.getHTML() === content) return;  // Avoid unnecessary updates
    editor.commands.setContent(content, false);  // false = don't emit update
  }, [content, editor]);

  return <EditorContent editor={editor} />;
}

// Available content commands
editor.commands.setContent('<p>New content</p>');      // Replace with HTML
editor.commands.setContent({ type: 'doc', content: [] });  // Replace with JSON
editor.commands.clearContent();                          // Empty the editor
editor.commands.insertContent('<p>Appended</p>');       // Insert at cursor
editor.commands.insertContentAt(0, '<p>At start</p>'); // Insert at position

// Get content in different formats
const html = editor.getHTML();   // '<p>Hello</p>'
const json = editor.getJSON();   // { type: 'doc', content: [...] }
const text = editor.getText();   // 'Hello'

Fix 5: Create Custom Nodes and Marks

Custom nodes let you embed non-standard content (e.g., callout boxes, mentions):

import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';

// Custom callout node — renders a styled box
const CalloutNode = Node.create({
  name: 'callout',
  group: 'block',
  content: 'inline*',

  addAttributes() {
    return {
      type: {
        default: 'info',  // 'info' | 'warning' | 'danger'
        parseHTML: element => element.getAttribute('data-type'),
        renderHTML: attributes => ({ 'data-type': attributes.type }),
      },
    };
  },

  parseHTML() {
    return [{ tag: 'div[data-type]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes(HTMLAttributes, { class: 'callout' }), 0];
  },

  // Render as a React component inside the editor
  addNodeView() {
    return ReactNodeViewRenderer(CalloutComponent);
  },
});

// React component for the node view
function CalloutComponent({ node, updateAttributes }: {
  node: any;
  updateAttributes: (attrs: Record<string, any>) => void;
}) {
  const typeColors = {
    info: 'bg-blue-50 border-blue-400',
    warning: 'bg-yellow-50 border-yellow-400',
    danger: 'bg-red-50 border-red-400',
  };

  return (
    <NodeViewWrapper>
      <div className={`border-l-4 p-4 rounded ${typeColors[node.attrs.type]}`}>
        <select
          value={node.attrs.type}
          onChange={e => updateAttributes({ type: e.target.value })}
          contentEditable={false}
        >
          <option value="info">Info</option>
          <option value="warning">Warning</option>
          <option value="danger">Danger</option>
        </select>
        <NodeViewContent />  {/* Editable content goes here */}
      </div>
    </NodeViewWrapper>
  );
}

// Custom mark — highlight text
import { Mark, mergeAttributes } from '@tiptap/core';

const Highlight = Mark.create({
  name: 'highlight',

  addAttributes() {
    return {
      color: {
        default: '#ffff00',
        parseHTML: element => element.style.backgroundColor,
        renderHTML: attributes => ({
          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);
      },
      unsetHighlight: () => ({ commands }) => {
        return commands.unsetMark(this.name);
      },
    };
  },
});

// Use custom extensions
const editor = useEditor({
  extensions: [StarterKit, CalloutNode, Highlight],
});

// Trigger custom commands
editor.chain().focus().setHighlight({ color: '#ff0' }).run();

Fix 6: Save and Restore Content

// Save to localStorage
function AutoSaveEditor() {
  const STORAGE_KEY = 'editor-content';

  const editor = useEditor({
    extensions: [StarterKit],
    content: localStorage.getItem(STORAGE_KEY) || '<p>Start writing...</p>',
    onUpdate: ({ editor }) => {
      // Save JSON (better for round-tripping than HTML)
      localStorage.setItem(STORAGE_KEY, JSON.stringify(editor.getJSON()));
    },
  });

  return <EditorContent editor={editor} />;
}

// Save to backend on explicit save
function ServerSaveEditor({ articleId }: { articleId: string }) {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Loading...</p>',
  });

  // Load from server
  useEffect(() => {
    fetch(`/api/articles/${articleId}`)
      .then(r => r.json())
      .then(data => {
        editor?.commands.setContent(data.content);
      });
  }, [articleId]);

  const handleSave = async () => {
    if (!editor) return;

    await fetch(`/api/articles/${articleId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        content: editor.getJSON(),  // Store JSON for lossless round-trip
        html: editor.getHTML(),     // Store HTML for easy rendering
        text: editor.getText(),     // Store text for search indexing
      }),
    });
  };

  return (
    <div>
      <EditorContent editor={editor} />
      <button onClick={handleSave}>Save</button>
    </div>
  );
}

// Render saved JSON content (without editor)
import { generateHTML } from '@tiptap/html';

function ContentRenderer({ json }: { json: object }) {
  const html = generateHTML(json, [StarterKit]);
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Still Not Working?

Editor is null after useEditoruseEditor returns null on the first render. Always guard with if (!editor) return null or use the editor?. optional chain before accessing methods. This is expected behavior, not a bug — ProseMirror needs the DOM to initialize.

editor.getJSON() returns { type: 'doc', content: [] } — the content failed to parse. This usually means the content HTML references a node type that isn’t registered. For example, if you saved content with the Image extension and reload without it, image nodes are dropped silently. Check that every extension used when saving is also registered when loading.

Commands return false and don’t execute — Tiptap commands return false when they can’t run in the current context. toggleBold() returns false if no text is selected and bold isn’t supported at the cursor position. Check editor.can().toggleBold() before running commands to verify they’re available. Also check that the cursor is focused in the editor (editor.chain().focus().toggleBold().run()).

Custom node view flickers or resets on type — if ReactNodeViewRenderer causes re-renders on every keystroke, make sure your node view component is stable. Wrap it in React.memo and avoid inline function definitions for updateAttributes calls. Node views re-render when their node attributes change, not on every document update.

Placeholder CSS isn’t showing — the Placeholder extension adds a data-placeholder attribute and relies on CSS to display it. Add this to your stylesheet:

.tiptap p.is-editor-empty:first-child::before {
  content: attr(data-placeholder);
  float: left;
  color: #adb5bd;
  pointer-events: none;
  height: 0;
}

For related editor issues, see Fix: React useState Not Updating and Fix: Next.js App Router Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles