Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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. The most important thing to internalize is that ProseMirror builds a schema at startup from the registered extensions, then validates every piece of content against that schema. Anything that doesn’t conform is silently stripped. So if you reload an HTML string that contains a <mark> element but you didn’t register the Highlight mark this time, the <mark> is dropped without warning.

Extension order matters more than the docs make obvious. When two extensions register node views for the same content (for example a custom CodeBlock and CodeBlockLowlight), the last one wins. Order also affects input rules and keyboard shortcuts — a later extension can shadow an earlier one’s binding. If you’re seeing “my custom extension’s command isn’t running,” check that it appears after StarterKit in the extensions array.

For React, custom node rendering has two distinct paths and they are not interchangeable. ReactNodeViewRenderer renders the node inside the editor’s contenteditable surface — use it when the node should still feel like editable content. ReactRenderer is for rendering outside the editor surface, typically for tippy.js menus or floating toolbars. Confusing the two leads to “my menu appears but I can’t interact with it” or “my node view loses focus on every keystroke.”

  • Extensions are not included by defaultuseEditor({ extensions: [] }) gives you a completely bare ProseMirror editor. Even paragraphs don’t work without the Paragraph extension.
  • Extensions have dependency requirementsBold requires Marks, 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 silently drops referenced nodes.
  • 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 immediatelyRender: false.

Diagnostic Timeline

A senior dev’s first guess is usually “reinstall the extensions and clear the cache.” That almost never helps. The real causes follow a different order.

Minute 0 — Open the browser console. If you see Schema: nodes must be of type 'doc' but received '...', you have an extension mismatch. If you see “Hydration failed,” you have an SSR issue. If you see nothing but the editor is blank, you have a schema-content mismatch (Tiptap is silently stripping everything).

Minute 3 — Print the parsed document. Add onUpdate: ({ editor }) => console.log(editor.getJSON()) and type a character. If you see { type: 'doc', content: [{ type: 'paragraph' }] }, the editor is alive but your initial content failed to parse. The content references node types that aren’t registered.

Minute 7 — Check extension order. If two extensions overlap (custom heading vs StarterKit.heading, custom code block vs StarterKit.codeBlock), the later one wins. Move the one you want to keep to the end of the array, or disable the conflicting one with StarterKit.configure({ heading: false }).

Minute 10 — Check Pro extension licensing. If you imported @tiptap-pro/extension-... or one of the cloud extensions (AiAgent, MathExtension, certain Mention variants), the editor refuses to register them without a valid TIPTAP_PRO_TOKEN env var or .npmrc auth. The free editor still mounts but the Pro feature is a no-op. Check the Network tab for failed registry requests.

Minute 13 — Decide between NodeView and ReactRenderer. If you’re rendering a custom node inside the editor (callout, mention chip, embed card), use ReactNodeViewRenderer and wrap content in NodeViewWrapper. If you’re rendering UI outside (floating menus, bubble menus), use ReactRenderer from @tiptap/react. Using the wrong one is why floating menus disappear on click or NodeViews lose focus.

Minute 18 — Check SSR. On Next.js App Router, add immediatelyRender: false to useEditor. This was the canonical hydration fix as of Tiptap 2.4. Without it, the editor renders on the server before the DOM is ready and React re-hydrates with a different tree.

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>',
    immediatelyRender: false,  // Required for SSR-safe mounting
    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 — note: register only once, don't pass two `placeholder` keys
    Placeholder.configure({
      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,
    immediatelyRender: false,
    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, NodeViewContent } 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.

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

Pro extensions silently no-op — Pro extensions (AiAgent, Comments, DocumentHistory, MathExtension) require an authenticated .npmrc entry pointing at https://registry.tiptap.dev. If installation succeeded but the extension does nothing, you likely installed a stale local copy. Re-run npm install with the registry token in your environment, then verify the network tab during editor mount — you should see a license check request, not a 401.

Bubble menu appears in the wrong place after scrollBubbleMenu is positioned by tippy.js relative to the selection. If the editor lives inside a scroll container and the menu is mounted at document.body, scrolling the inner container can desync them. Pass tippyOptions: { appendTo: () => editorContainerRef.current } so the menu shares the scroll context.

Collaborative editing duplicates characters — if you’re using @hocuspocus or y-prosemirror, you must disable StarterKit.history. The Yjs undo manager replaces ProseMirror’s history. Running both produces double-applied operations on remote ack.

For related editor issues, see Fix: React useState Not Updating, Fix: Next.js Hydration Failed, Fix: React Server Components Error, and Fix: MDX 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