Fix: Tiptap Not Working — Editor Not Rendering, Extensions Missing, or Content Not Saving
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 contentOr 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 textOr 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 default —
useEditor({ extensions: [] })gives you a completely bare ProseMirror editor. Even paragraphs don’t work without theParagraphextension.StarterKitbundles the most common extensions, but any feature beyond the basics must be explicitly imported and registered. - Extensions have dependency requirements —
BoldrequiresMarks,LinkrequiresBoldin 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
contenttouseEditorand then update it later witheditor.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
documentand 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 withssr: 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-kitimport { 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-styleimport 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 useEditor — useEditor 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Mapbox GL JS Not Working — Map Not Rendering, Markers Missing, or Access Token Invalid
How to fix Mapbox GL JS issues — access token setup, React integration with react-map-gl, markers and popups, custom layers, geocoding, directions, and Next.js configuration.
Fix: React PDF Not Working — PDF Not Rendering, Worker Error, or Pages Blank
How to fix react-pdf and @react-pdf/renderer issues — PDF viewer setup, worker configuration, page rendering, text selection, annotations, and generating PDFs in React.
Fix: Million.js Not Working — Compiler Errors, Components Not Optimized, or React Compatibility Issues
How to fix Million.js issues — compiler setup with Vite and Next.js, block() optimization rules, component compatibility constraints, automatic mode, and debugging performance gains.