The Perfect Rich Text Editor for Next.js 16: Why Tiptap Beats Them All, and How to Set It Up with TypeScript
Let’s be honest: choosing a WYSIWYG editor for a React project is always a pain. If you've been in web dev for a while, you've probably been through the hell of customizing TinyMCE, trying to wrap your head around the intricacies of Draft.js (which hasn't been actively developed in ages), or fighting against Quill's default styles that break your entire UI.
When you're building a modern B2B/B2C product on Next.js 16 with strictly typed TypeScript, you need a tool that doesn't dictate its own rules. You need an editor that integrates easily, is perfectly typed, and gives you 100% control over the look and feel.
And that's where Tiptap steps in.
What is Tiptap?
Tiptap is a "headless" framework for creating text editors, built on top of the legendary and rock-solid ProseMirror.
The word "headless" is key here. Tiptap doesn't come with a pre-built interface. There are no built-in "Bold" or "Italic" buttons, or ready-made toolbars. It gives you a powerful API for content manipulation, and how your buttons look and where they are placed is entirely up to you. This fits perfectly with the philosophy of Tailwind CSS, which is often used in modern projects.
Why is Tiptap better than other solutions?
As a fullstack developer who has integrated more than one editor into his projects, I can highlight several killer features of Tiptap:
Absolute UI freedom: No
!importantin CSS to override editor styles. You build the toolbar yourself using your favorite components (for example, using icons fromlucide-react).TypeScript-First: Tiptap is written in TypeScript and has excellent typing. No compile-time surprises - your editor will behave predictably, and IDE autocompletion will work like magic.
Modular architecture: You aren't dragging megabytes of unnecessary code into your bundle. Need YouTube support? Install a separate package. Need tables? Install extensions for tables. Your build stays lightweight and fast.
SSR Friendly: It works beautifully with Next.js (App Router) without causing hydration errors, provided you initialize the client-side component correctly.
Powerful HTML parsing: With the
@tiptap/htmlpackage, you can easily generate clean HTML on the server for SEO or newsletters without having to instantiate the editor instance itself.
Installation: Assembling our toolkit
Since we're using a modular approach, we need to install the Tiptap core, a base set of extensions, and the plugins that will make our editor truly powerful (e.g., syntax highlighting for code blocks, image support, links, and tables).
Let's open the terminal and install the packages using pnpm:
Bash
pnpm add @tiptap/react @tiptap/starter-kit @tiptap/html
This is our core. The starter-kit includes the essentials: paragraphs, headings, lists, bold text, etc. Now let's add some "muscle" - extensions for complex content that we'll definitely need in a modern blog:
Bash
pnpm add @tiptap/extension-image @tiptap/extension-link @tiptap/extension-placeholder @tiptap/extension-text-align @tiptap/extension-underline @tiptap/extension-horizontal-rule @tiptap/extension-youtube
If your blog features technical content (like this one), beautiful code highlighting is vital. Tiptap supports lowlight (which uses highlight.js under the hood):
Bash
pnpm add @tiptap/extension-code-block-lowlight lowlight highlight.js
And finally, if you plan to work with data or comparisons, let's add tables:
Bash
pnpm add @tiptap/extension-table @tiptap/extension-table-row @tiptap/extension-table-header @tiptap/extension-table-cell
That's it! Our package.json is loaded with the necessary version 3.x dependencies, and we're ready to start writing the React editor component itself.
Step 1: Building the Perfect Editor Component (Client-Side)
Most tutorials suggest dumping all buttons into one file, which turns the component into an unreadable mess. As engineers, we'll separate the logic: we'll move the MenuBar (our custom toolbar) into a separate section, and keep the EditorContent itself clean.
For icons, we use lucide-react-they look modern and are easily styled. We'll also implement custom logic for image uploads via our API and adding YouTube videos.
Here is our complete CustomEditor.tsx. Pay attention to how elegantly we connect Markdown, tables, and code highlighting:
TypeScript
'use client'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
import TextAlign from '@tiptap/extension-text-align'
import Underline from '@tiptap/extension-underline'
// New extensions: YouTube and Tables
import Youtube from '@tiptap/extension-youtube'
import { type Editor, EditorContent, type JSONContent, useEditor } from '@tiptap/react'
// Base extensions
import StarterKit from '@tiptap/starter-kit'
import bash from 'highlight.js/lib/languages/bash'
import css from 'highlight.js/lib/languages/css'
// highlight.js languages
import javascript from 'highlight.js/lib/languages/javascript'
import json from 'highlight.js/lib/languages/json'
import typescript from 'highlight.js/lib/languages/typescript'
import xml from 'highlight.js/lib/languages/xml'
import { createLowlight } from 'lowlight'
// Icons
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Code,
Columns,
Eraser,
Heading1,
Heading2,
Heading3,
Heading4,
Image as ImageIcon,
Italic,
Link as LinkIcon,
List,
ListOrdered,
Minus,
Plus,
Quote,
Redo,
Rows,
Table as TableIcon,
Trash2,
Underline as UnderlineIcon,
Undo,
Youtube as YoutubeIcon,
} from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { Markdown } from 'tiptap-markdown'
// Registering languages for code highlighting
const lowlight = createLowlight()
lowlight.register('javascript', javascript)
lowlight.register('typescript', typescript)
lowlight.register('bash', bash)
lowlight.register('json', json)
lowlight.register('css', css)
lowlight.register('xml', xml)
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
interface CustomEditorProps {
initialData: JSONContent | null
onChange: (content: JSONContent | null) => void
placeholder?: string
}
/* -------------------------------------------------------------------------- */
/* UI Components */
/* -------------------------------------------------------------------------- */
const ToolbarButton = ({
onClick,
isActive = false,
title,
children,
}: {
onClick: () => void
isActive?: boolean
title?: string
children: React.ReactNode
}) => (
<button
type="button"
onClick={onClick}
title={title}
className={`tf-button_tiptap ${isActive ? 'active' : ''}`}
>
{children}
</button>
)
const MenuBar = ({ editor }: { editor: Editor | null }) => {
const uploadImage = useCallback(async () => {
if (!editor) return
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
if (file.size > 5 * 1024 * 1024) {
alert('Max image size is 5MB')
return
}
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!res.ok) {
alert('Error uploading image')
return
}
const { url } = await res.json()
editor.chain().focus().setImage({ src: url }).run()
} catch (err) {
console.error(err)
alert('Error uploading image')
}
}
input.click()
}, [editor])
const setLink = useCallback(() => {
if (!editor) return
const previousUrl = editor.getAttributes('link').href
const url = window.prompt('URL link', previousUrl)
if (url === null) return
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}, [editor])
const addYoutubeVideo = useCallback(() => {
if (!editor) return
const url = prompt('Enter YouTube video URL:')
if (url) {
editor.commands.setYoutubeVideo({
src: url,
width: 800,
height: 450,
})
}
}, [editor])
if (!editor) return null
return (
<div className="toolbar flex flex-wrap gap-2 p-2 border-b">
{/* Group 1: Text formatting */}
<div className="group flex items-center gap-1">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Bold"
>
<Bold size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Italic"
>
<Italic size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
title="Underline"
>
<UnderlineIcon size={18} />
</ToolbarButton>
</div>
<div className="divider w-px h-6 bg-gray-300 mx-1" />
{/* Group 2: Headings (H1 - H4) */}
<div className="group flex items-center gap-1">
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Heading 1"
>
<Heading1 size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<Heading3 size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
isActive={editor.isActive('heading', { level: 4 })}
title="Heading 4"
>
<Heading4 size={18} />
</ToolbarButton>
</div>
<div className="divider w-px h-6 bg-gray-300 mx-1" />
{/* Group 3: Alignment */}
<div className="group flex items-center gap-1">
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
isActive={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<AlignLeft size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<AlignCenter size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
isActive={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<AlignRight size={18} />
</ToolbarButton>
</div>
<div className="divider w-px h-6 bg-gray-300 mx-1" />
{/* Group 4: Lists and Quotes */}
<div className="group flex items-center gap-1">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
<List size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Ordered List"
>
<ListOrdered size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Quote"
>
<Quote size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive('codeBlock')}
title="Code Block"
>
<Code size={18} />
</ToolbarButton>
</div>
<div className="divider w-px h-6 bg-gray-300 mx-1" />
{/* Group 5: Inserts (Media, Tables, Dividers) */}
<div className="group flex items-center gap-1">
<ToolbarButton onClick={setLink} isActive={editor.isActive('link')} title="Insert Link">
<LinkIcon size={18} />
</ToolbarButton>
<ToolbarButton onClick={uploadImage} title="Upload Image">
<ImageIcon size={18} />
</ToolbarButton>
<ToolbarButton
onClick={addYoutubeVideo}
isActive={editor.isActive('youtube')}
title="Insert YouTube Video"
>
<YoutubeIcon size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
}
title="Insert Table"
>
<TableIcon size={18} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Divider"
>
<Minus size={18} />
</ToolbarButton>
</div>
{/* Group 6: Dynamic Table Menu */}
{editor.isActive('table') && (
<>
<div className="divider w-px h-6 bg-gray-300 mx-1" />
<div className="group flex items-center gap-1 bg-gray-50 rounded p-1">
<ToolbarButton
onClick={() => editor.chain().focus().addColumnAfter().run()}
title="Add Column"
>
<Columns size={16} /> <Plus size={12} className="inline -ml-1" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().deleteColumn().run()}
title="Delete Column"
>
<Columns size={16} /> <Minus size={12} className="inline -ml-1 text-red-500" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().addRowAfter().run()}
title="Add Row"
>
<Rows size={16} /> <Plus size={12} className="inline -ml-1" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().deleteRow().run()}
title="Delete Row"
>
<Rows size={16} /> <Minus size={12} className="inline -ml-1 text-red-500" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().deleteTable().run()}
title="Delete Table"
>
<Trash2 size={16} className="text-red-500" />
</ToolbarButton>
</div>
</>
)}
{/* Group 7: Undo/Redo and Clear */}
<div className="group flex items-center gap-1 ml-auto">
<ToolbarButton
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
title="Clear Formatting"
>
<Eraser size={18} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} title="Undo">
<Undo size={18} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} title="Redo">
<Redo size={18} />
</ToolbarButton>
</div>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Editor */
/* -------------------------------------------------------------------------- */
const CustomEditor = ({
initialData,
onChange,
placeholder = 'Start typing...',
}: CustomEditorProps) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4] },
codeBlock: false,
}),
CodeBlockLowlight.configure({ lowlight }),
Underline,
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: { class: 'editor-link' },
}),
// 🟢 ADDING MARKDOWN PARSER
Markdown.configure({
html: false, // Disable HTML to keep clean Markdown
transformPastedText: true, // Enable parsing on paste
transformCopiedText: false,
}),
Image.configure({
inline: false,
allowBase64: false,
HTMLAttributes: { class: 'editor-image' },
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Placeholder.configure({
placeholder,
}),
// YouTube settings
Youtube.configure({
controls: true,
nocookie: true,
HTMLAttributes: {
class: 'editor-youtube aspect-video w-full rounded-lg',
},
}),
// Table settings
Table.configure({
resizable: true,
HTMLAttributes: {
class: 'editor-table min-w-full border-collapse border border-gray-300 my-4',
},
}),
TableRow.configure({
HTMLAttributes: { class: 'border-b border-gray-300' },
}),
TableHeader.configure({
HTMLAttributes: {
class: 'border border-gray-300 bg-gray-100 p-2 font-bold text-left',
},
}),
TableCell.configure({
HTMLAttributes: { class: 'border border-gray-300 p-2' },
}),
],
content: initialData ?? undefined,
onUpdate: ({ editor }) => {
onChange(editor.getJSON())
},
immediatelyRender: false,
})
useEffect(() => {
if (editor && initialData) {
if (editor.isEmpty) {
editor.commands.setContent(initialData)
}
}
}, [initialData, editor])
return (
<div className="editor-wrapper">
<MenuBar editor={editor} />
<div className="editor-content tiptap-content p-4 min-h-75 border border-t-0 rounded-b focus:outline-none">
<EditorContent editor={editor} />
</div>
</div>
)
}
export default CustomEditor
Step 2: Server-Side Rendering (SSR) and Parsing for SEO
Many people make the same mistake: they store ready-made HTML in the database and then try to edit it, or even worse, they drag the entire editor library to the client-side just to display an article to the user.
We work smarter with Tiptap: we store content in JSON format. This gives us flexibility, but a question arises: how do we render this on the server into clean HTML for fast page delivery and perfect SEO?
The answer is the @tiptap/html package. We create a tiptap.ts utility that performs two vital functions:
It converts JSON to HTML for the frontend.
It extracts clean text (without tags) for generating
meta descriptionand OpenGraph tags.
Pay attention to the "hack" with the warning blocker - it will save your nerves and keep your server logs clean during the build.
TypeScript
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
import TextAlign from '@tiptap/extension-text-align'
import Underline from '@tiptap/extension-underline'
import Youtube from '@tiptap/extension-youtube'
import { generateHTML } from '@tiptap/html'
import StarterKit from '@tiptap/starter-kit'
import bash from 'highlight.js/lib/languages/bash'
import css from 'highlight.js/lib/languages/css'
import javascript from 'highlight.js/lib/languages/javascript'
import json from 'highlight.js/lib/languages/json'
import typescript from 'highlight.js/lib/languages/typescript'
import xml from 'highlight.js/lib/languages/xml'
import { createLowlight } from 'lowlight'
// 🟢 INTERFACE: Describing the Tiptap node structure for strict typing
interface TiptapNode {
type?: string
text?: string
content?: TiptapNode[]
[key: string]: unknown // For additional node attributes
}
// 🟢 TIPTAP WARNING BLOCKER
// Since generateHTML in newer versions throws false warnings
// about duplicate extensions during rapid rendering, we simply hide them from the console.
if (typeof console !== 'undefined') {
const originalWarn = console.warn
console.warn = (...args) => {
if (typeof args[0] === 'string' && args[0].includes('Duplicate extension names found')) {
return // Ruthlessly silence this spam
}
originalWarn(...args) // Pass through other important warnings
}
}
const lowlight = createLowlight()
lowlight.register('javascript', javascript)
lowlight.register('typescript', typescript)
lowlight.register('bash', bash)
lowlight.register('json', json)
lowlight.register('css', css)
lowlight.register('xml', xml)
// 🟢 CHANGE: Turning the constant into a FUNCTION!
// Now every call to generateHTML receives fresh, untouched objects.
const getTiptapExtensions = () => [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4] },
codeBlock: false,
}),
CodeBlockLowlight.configure({ lowlight }),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Underline,
Link.configure({ HTMLAttributes: { class: 'editor-link' } }),
Image.configure({ HTMLAttributes: { class: 'editor-image' } }),
Youtube.configure({
HTMLAttributes: { class: 'editor-youtube aspect-video w-full rounded-lg' },
}),
Table.configure({
HTMLAttributes: {
class: 'editor-table min-w-full border-collapse border border-gray-300 my-4',
},
}),
TableRow.configure({
HTMLAttributes: { class: 'border-b border-gray-300' },
}),
TableHeader.configure({
HTMLAttributes: {
class: 'border border-gray-300 bg-gray-100 p-2 font-bold text-left',
},
}),
TableCell.configure({
HTMLAttributes: { class: 'border border-gray-300 p-2' },
}),
]
// 1. Generate HTML for rendering on the site
export function getHTMLFromTiptap(data: string | null | undefined): string {
if (!data) return ''
// FIX: Initialize the variable with the TiptapNode type
let tiptapJson: TiptapNode
try {
tiptapJson = JSON.parse(data) as TiptapNode
} catch {
return data
}
try {
// 🟢 Call the getTiptapExtensions() function
return generateHTML(tiptapJson, getTiptapExtensions())
} catch (error) {
console.error('TipTap render error:', error)
return ''
}
}
// 2. Generate plain text (no tags) for SEO (Meta Description)
export function getTextFromTiptap(data: string | null | undefined): string {
if (!data) return ''
let tiptapJson: TiptapNode
try {
tiptapJson = JSON.parse(data) as TiptapNode
} catch {
return data.replace(/<[^>]*>?/gm, '')
}
// FIX: Replaced 'any' with our TiptapNode interface for recursion
const extractText = (node: TiptapNode): string => {
if (node.type === 'text') return node.text || ''
if (node.content) {
return node.content.map(extractText).join(node.type === 'paragraph' ? ' ' : '')
}
return ''
}
const plainText = extractText(tiptapJson).trim()
return plainText.length > 160 ? `${plainText.substring(0, 157)}...` : plainText
}
Step 3: The Secret Ingredient for Next.js (next.config.ts)
Before we start embedding the editor into our components, there’s one non-obvious nuance that could cost you a few hours of debugging. Since Tiptap uses modern ESM modules, Next.js can sometimes "stumble" over them during the build process (especially in Docker containers or monorepos).
To ensure everything compiles perfectly, we need to instruct Next.js to force the transpilation of the Tiptap packages. Open next.config.ts and add one line:
TypeScript
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// 🟢 Mandatory for Tiptap to work correctly in Next.js App Router
transpilePackages: ['@tiptap/react', '@tiptap/core'],
// ... other configurations (e.g., output: 'standalone' for Docker)
}
export default nextConfig
Step 4: Connecting the Editor in the Admin Panel (State Management)
Now let's look at how this editor lives in an actual blog post creation form. The golden rule of modern development: we never store raw HTML in the database while editing. We store JSON. This allows us to easily change the structure in the future, render content in mobile apps (React Native), or update CSS classes without breaking everything.
Here is the gist of the logic from my blog creation form. No fluff, just how we connect the CustomEditor and prepare the data for the server via Server Actions (or API):
TypeScript
'use client'
import type { JSONContent } from '@tiptap/react'
import { useState, type FormEvent } from 'react'
import CustomEditor from './CustomEditor' // Our component from Step 1
export default function CreateBlogForm() {
// 🟢 Storing content specifically in Tiptap's JSONContent format
const [content, setContent] = useState<JSONContent | null>(null)
const [title, setTitle] = useState('')
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData()
formData.set('title', title)
// Convert the JSON object to a string for backend transmission
formData.set('content', content ? JSON.stringify(content) : '')
// Then send formData to a Server Action or API
// await createBlog(formData)
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div>
<label className="block mb-2 font-bold">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="border p-2 w-full rounded"
/>
</div>
<div>
<label className="block mb-2 font-bold">Article Content</label>
{/* 🟢 Connecting our editor */}
<CustomEditor
initialData={content}
onChange={setContent}
placeholder="Start writing your awesome article..."
/>
</div>
<button type="submit" className="bg-blue-600 text-white px-6 py-2 rounded">
Publish
</button>
</form>
)
}
Everything is as clean as it gets. The CustomEditor component handles its internal state and returns the finished JSON to us via the onChange callback.
Step 5: Frontend. Rendering HTML like a Senior (No dangerouslySetInnerHTML)
And now, the most important part. After fetching the content from the database, we run it through our server-side utility getHTMLFromTiptap() (from Step 2). But how do we display this HTML on the page?
Beginners often use dangerouslySetInnerHTML. That's bad for two reasons:
You open the door to XSS vulnerabilities.
Internal links (
<a>tags) will trigger a full page reload, ignoring Next.js SPA navigation.
We will use the html-react-parser package. It allows us to intercept HTML nodes "on the fly" and replace them with React components. For example, we'll replace standard links with the Link component from next/link, and we'll pass <pre><code> code blocks to our custom highlighter component!
Here is how rendering looks on the frontend:
TypeScript
'use client'
import parse, { type DOMNode, domToReact, Element } from 'html-react-parser'
import Link from 'next/link'
import { useMemo } from 'react'
import { getHTMLFromTiptap } from '@/lib/tiptap'
import CodeBlockWrapper from './CodeBlockWrapper' // Your custom syntax highlighter component
// For example, this object comes from your database
interface BlogData {
title: string
content: string // The JSON string we stored earlier
}
export default function BlogDetail({ blog }: { blog: BlogData }) {
// 🟢 1. Convert JSON from the DB into an HTML string
const htmlContent = useMemo(() => {
if (!blog.content) return ''
return getHTMLFromTiptap(blog.content)
}, [blog.content])
return (
<article className="blog-details">
<h1 className="text-4xl font-bold mb-8">{blog.title}</h1>
<div className="blog-content-styles prose max-w-none">
{/* 🟢 2. Parse HTML and replace nodes with React components */}
{parse(htmlContent, {
replace: (domNode) => {
// Intercept all links
if (domNode instanceof Element && domNode.name === 'a') {
const href = domNode.attribs.href || '#'
const isExternal = href.startsWith('http')
// Use Next.js Link instead of a standard <a>
return (
<Link href={href} target={isExternal ? '_blank' : undefined}>
{domToReact(domNode.children as DOMNode[])}
</Link>
)
}
// Intercept code blocks for beautiful highlighting
if (domNode instanceof Element && domNode.name === 'pre') {
const codeElement = domNode.children.find(
(c) => c instanceof Element && c.name === 'code'
)
const codeContent = codeElement instanceof Element
? codeElement.children.map((c) => (c.type === 'text' ? c.data : '')).join('')
: domNode.children.map((c) => (c.type === 'text' ? c.data : '')).join('')
const lang = codeElement instanceof Element ? codeElement.attribs.class : ''
const language = lang?.replace('language-', '')
// Send the raw code to our custom Highlighter
return <CodeBlockWrapper code={codeContent} language={language} />
}
return undefined
},
})}
</div>
</article>
)
}
Step 6: CSS Magic - Styling the Admin Panel and Frontend
One of the biggest perks of Tiptap is that it doesn't force its own base styles on you. Your editor and your published content will look exactly the way you write them in CSS.
To make your life easier, I've put together a ready-to-use CSS file that solves several problems at once:
It makes the admin interface clean and intuitive.
It styles complex elements (tables, quotes, code) for the frontend.
It contains a "hack" for a beautiful "Key Takeaways" block, gluing an
<h4>heading and the following<ul>list into a single seamless card.
Just create a tiptap.css file (or add this to your global globals.css):
(The CSS block remains the same as provided in your prompt, as CSS syntax is universal)
Why is this CSS so cool?
Pay attention to the "Key Takeaways" block. In the admin panel, you simply create an H4 heading and immediately follow it with a bulleted list. CSS, using the adjacent sibling selector h4 + ul, automatically "glues" them into a single beautiful card with a colored background and an accent line on the left.
You don't need to write custom Tiptap plugins to create info panels - proper CSS solves this task more simply and elegantly!
Conclusion: Tiptap is an Investment in Stability
Integrating Tiptap into a Next.js 16 project requires a bit more manual work at the start compared to "plug-and-play" solutions like Quill or Draft.js. But this investment pays off instantly.
You get:
Clean React/TypeScript code without any "black boxes."
Lightning-fast SSR rendering, because we convert JSON to HTML on the server.
Perfect SEO, because Google receives clean, semantic HTML without client-side editor rendering.
Absolute design control thanks to Tailwind/CSS and the
html-react-parserpackage.
I hope this guide saves you hours of debugging. Take this code, adapt it to your SaaS projects or blogs, and write content with pleasure!
(Prepared for developers who value clean code and robust architecture).

Коментарі (0)
Leave a comment