Ідеальний Rich Text Editor для Next.js 16: Чому Tiptap виграє у всіх, і як його налаштувати з TypeScript
Давайте відверто: вибір WYSIWYG-редактора для React-проекту - це завжди біль. Якщо ви давно у веб-розробці, то напевно проходили через пекло кастомізації TinyMCE, намагалися зрозуміти хитросплетіння Draft.js (який вже давно не розвивається) або боролися з дефолтними стилями Quill, які ламали вам весь UI.
Коли ти будуєш сучасний B2B/B2C продукт на Next.js 16 із суворо типізованим TypeScript, тобі потрібен інструмент, який не буде диктувати свої правила. Тобі потрібен редактор, який легко інтегрується, ідеально типізується і дає 100% контролю над зовнішнім виглядом.
І тут на сцену виходить Tiptap.
Що таке Tiptap?
Tiptap це "headless" (безголовий) фреймворк для створення редакторів тексту, побудований поверх легендарного і залізобетонного ProseMirror.
Слово "headless" тут ключове. Tiptap не постачається з готовим інтерфейсом. У ньому немає вбудованих кнопок "Жирний", "Курсив" або готових тулбарів. Він дає вам потужний API для роботи з контентом, а те, як виглядатимуть ваші кнопки і де вони будуть розташовані - вирішуєте тільки ви. Це ідеально лягає на філософію роботи з Tailwind CSS, який часто використовується в сучасних проектах.
Чому Tiptap кращий за інші рішення?
Як Fullstack-розробник, який інтегрував не один редактор у свої проєкти, я можу виділити кілька кілер-фіч Tiptap:
Абсолютна свобода UI: Ніяких
!importantу CSS, щоб перебити стилі редактора. Ви самі верстаєте тулбар за допомогою своїх улюблених компонентів (наприклад, використовуючи іконки зlucide-react).TypeScript-First: Tiptap написаний на TypeScript і має відмінну типізацію. Ніяких сюрпризів під час компіляції - ваш редактор буде працювати передбачувано, а автодоповнення в IDE працюватиме як магія.
Модульна архітектура: Ви не тягнете в бандл мегабайти непотрібного коду. Потрібна підтримка YouTube-відео? Ставите окремий пакет. Потрібні таблиці? Ставите екстеншени для таблиць. Ваша збірка залишається легкою і швидкою.
SSR Friendly: Він чудово працює з Next.js (App Router), не викликаючи помилок гідратації, якщо правильно налаштувати ініціалізацію клієнтського компонента.
Потужний парсинг HTML: З пакетом
@tiptap/htmlви можете легко генерувати чистий HTML на сервері для SEO або відправки розсилок, не підіймаючи сам екземпляр редактора.
Встановлення: Збираємо наш інструментарій
Оскільки ми використовуємо модульний підхід, нам потрібно встановити ядро Tiptap, базовий набір розширень та ті плагіни, які зроблять наш редактор по-справжньому потужним (наприклад, підсвітку синтаксису для блоків коду, підтримку зображень, посилань та таблиць).
Відкриваємо термінал і встановлюємо пакети за допомогою pnpm:
Bash
pnpm add @tiptap/react @tiptap/starter-kit @tiptap/html
Це наше ядро. starter-kit містить найнеобхідніші речі: параграфи, заголовки, списки, жирний текст тощо. Тепер додамо "м'язи" - розширення для складного контенту, які нам точно знадобляться в сучасному блозі:
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
Якщо у вашому блозі буде технічний контент (як у цьому), нам життєво необхідна красива підсвітка коду. Tiptap підтримує lowlight (який використовує highlight.js під капотом):
Bash
pnpm add @tiptap/extension-code-block-lowlight lowlight highlight.js
І, нарешті, якщо ви плануєте працювати з даними або порівняннями - додаємо таблиці:
Bash
pnpm add @tiptap/extension-table @tiptap/extension-table-row @tiptap/extension-table-header @tiptap/extension-table-cell
Усе! Наш package.json заряджений потрібними залежностями версії 3.x, і ми готові переходити до написання самого React-компонента редактора.
Крок 1: Створюємо ідеальний компонент редактора (Клієнтська частина)
Більшість туторіалів пропонують скинути всі кнопки в один файл, що перетворює компонент на нечитабельний жах. Ми ж, як інженери, розділимо логіку: винесемо MenuBar (наш кастомний тулбар) окремо, а сам EditorContent залишимо чистим.
Для іконок ми використовуємо lucide-react - вони виглядають сучасно і відмінно стилізуються. Також ми реалізуємо кастомну логіку для завантаження зображень через наш API та додавання YouTube-відео.
Ось наш готовий CustomEditor.tsx. Зверніть увагу на те, як елегантно ми підключаємо Markdown, таблиці та підсвітку коду:
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'
// Нові розширення: YouTube та Таблиці
import Youtube from '@tiptap/extension-youtube'
import { type Editor, EditorContent, type JSONContent, useEditor } from '@tiptap/react'
// Базові розширення
import StarterKit from '@tiptap/starter-kit'
import bash from 'highlight.js/lib/languages/bash'
import css from 'highlight.js/lib/languages/css'
// highlight.js мови
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'
// Іконки
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'
// Реєструємо мови для підсвітки коду
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)
/* -------------------------------------------------------------------------- */
/* Типи */
/* -------------------------------------------------------------------------- */
interface CustomEditorProps {
initialData: JSONContent | null
onChange: (content: JSONContent | null) => void
placeholder?: string
}
/* -------------------------------------------------------------------------- */
/* UI компоненти */
/* -------------------------------------------------------------------------- */
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('Максимальний розмір зображення - 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('Помилка завантаження зображення')
return
}
const { url } = await res.json()
editor.chain().focus().setImage({ src: url }).run()
} catch (err) {
console.error(err)
alert('Помилка завантаження зображення')
}
}
input.click()
}, [editor])
const setLink = useCallback(() => {
if (!editor) return
const previousUrl = editor.getAttributes('link').href
const url = window.prompt('URL посилання', 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('Введіть посилання на YouTube відео:')
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">
{/* Група 1: Форматування тексту */}
<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" />
{/* Група 2: Заголовки (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" />
{/* Група 3: Вирівнювання */}
<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" />
{/* Група 4: Списки та Цитати */}
<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" />
{/* Група 5: Вставки (Медіа, Таблиці, Лінії) */}
<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>
{/* Виклик setHorizontalRule() береться зі StarterKit */}
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Divider"
>
<Minus size={18} />
</ToolbarButton>
</div>
{/* Група 6: Динамічне меню керування таблицею */}
{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>
</>
)}
{/* Група 7: Історія та Очищення */}
<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' },
}),
// 🟢 ДОДАЄМО MARKDOWN ПАРСЕР
Markdown.configure({
html: false, // Вимикаємо HTML, щоб зберігати чистий Markdown
transformPastedText: true, // Вмикаємо парсинг при вставці
transformCopiedText: false,
// extensions видаляємо, вони тут не потрібні
}),
Image.configure({
inline: false,
allowBase64: false,
HTMLAttributes: { class: 'editor-image' },
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Placeholder.configure({
placeholder,
}),
// Налаштування YouTube
Youtube.configure({
controls: true,
nocookie: true,
HTMLAttributes: {
class: 'editor-youtube aspect-video w-full rounded-lg',
},
}),
// Налаштування таблиць
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
Крок 2: Серверний рендеринг (SSR) та парсинг для SEO
Багато хто робить одну й ту саму помилку: зберігає в базу готовий HTML, а потім намагається його редагувати, або, що ще гірше, тягне всю бібліотеку редактора на клієнтську частину просто для того, щоб показати статтю користувачу.
Ми працюємо з Tiptap розумніше: зберігаємо контент у форматі JSON. Це дає нам гнучкість, але виникає питання: як відрендерити це на сервері у чистий HTML для швидкої віддачі сторінки та ідеального SEO?
Відповідь - пакет @tiptap/html. Ми створюємо утиліту tiptap.ts, яка виконуватиме дві важливі функції:
Перетворюватиме JSON на HTML для фронтенду.
Витягуватиме чистий текст (без тегів) для генерації
meta descriptionта OpenGraph тегів.
Зверніть увагу на "хак" з блокувальником попереджень - це збереже ваші нерви і чистоту серверних логів під час білду.
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'
// 🟢 ІНТЕРФЕЙС: Описуємо структуру вузла Tiptap для строгої типізації
interface TiptapNode {
type?: string
text?: string
content?: TiptapNode[]
[key: string]: unknown // Для додаткових атрибутів вузлів
}
// 🟢 БЛОКУВАЛЬНИК СМІТТЄВИХ ПОПЕРЕДЖЕНЬ TIPTAP
// Оскільки generateHTML у нових версіях видає хибні попередження
// про дублікати при швидкому рендерингу, ми просто приховуємо їх з консолі.
if (typeof console !== 'undefined') {
const originalWarn = console.warn
console.warn = (...args) => {
if (typeof args[0] === 'string' && args[0].includes('Duplicate extension names found')) {
return // Безжально глушимо цей спам
}
originalWarn(...args) // Інші важливі попередження пропускаємо
}
}
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)
// 🟢 ЗМІНА: Перетворюємо константу на ФУНКЦІЮ!
// Тепер кожен виклик generateHTML отримує свіжі, незаймані об'єкти.
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. Генерація HTML для виводу на сайті
export function getHTMLFromTiptap(data: string | null | undefined): string {
if (!data) return ''
// ВИПРАВЛЕННЯ: Ініціалізуємо змінну з типом TiptapNode
let tiptapJson: TiptapNode
try {
tiptapJson = JSON.parse(data) as TiptapNode
} catch {
return data
}
try {
// 🟢 Викликаємо функцію getTiptapExtensions()
return generateHTML(tiptapJson, getTiptapExtensions())
} catch (error) {
console.error('TipTap render error:', error)
return ''
}
}
// 2. Генерація чистого тексту (без тегів) для 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, '')
}
// ВИПРАВЛЕННЯ: Замінили 'any' на наш інтерфейс TiptapNode для рекурсії
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
}
Крок 3: Секретний інгредієнт для Next.js (next.config.ts)
Перш ніж ми почнемо вбудовувати редактор у наші компоненти, є один неочевидний нюанс, який може коштувати вам кількох годин дебагінгу. Оскільки Tiptap використовує сучасні ESM-модулі, Next.js іноді може "спіткнутися" об них під час збірки (особливо в Docker-контейнерах або монорепозиторіях).
Щоб усе компілювалося ідеально, нам потрібно вказати Next.js примусово транспілювати пакети Tiptap. Відкриваємо next.config.ts і додаємо один рядок:
TypeScript
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// 🟢 Обов'язково для коректної роботи Tiptap у Next.js App Router
transpilePackages: ['@tiptap/react', '@tiptap/core'],
// ... інші ваші налаштування (наприклад, output: 'standalone' для Docker)
}
export default nextConfig
Крок 4: Підключаємо редактор в Адмінці (Керування станом)
Тепер давайте подивимось, як цей редактор живе в реальній формі створення статті. Головне правило сучасної розробки: ми ніколи не зберігаємо сирий HTML у базу під час редагування. Ми зберігаємо JSON. Це дозволяє нам у майбутньому легко міняти структуру, рендерити контент на мобільних додатках (React Native) або безболісно міняти класи.
Ось вичавка логіки з моєї форми створення блогу. Ніякого зайвого коду, тільки те, як ми підключаємо CustomEditor і готуємо дані для відправки на сервер через Server Actions (або API):
TypeScript
'use client'
import type { JSONContent } from '@tiptap/react'
import { useState, type FormEvent } from 'react'
import CustomEditor from './CustomEditor' // Наш компонент з Кроку 1
export default function CreateBlogForm() {
// 🟢 Зберігаємо контент саме у форматі JSONContent від Tiptap
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)
// Перетворюємо JSON-об'єкт у рядок для передачі на бекенд
formData.set('content', content ? JSON.stringify(content) : '')
// Далі відправляємо formData у Server Action або API
// await createBlog(formData)
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div>
<label className="block mb-2 font-bold">Заголовок</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">Контент статті</label>
{/* 🟢 Підключаємо наш редактор */}
<CustomEditor
initialData={content}
onChange={setContent}
placeholder="Почніть писати свою круту статтю..."
/>
</div>
<button type="submit" className="bg-blue-600 text-white px-6 py-2 rounded">
Опублікувати
</button>
</form>
)
}
Все максимально чисто. Компонент CustomEditor сам піклується про внутрішній стан і повертає нам готовий JSON через колбек onChange.
Крок 5: Фронтенд. Рендеримо HTML як Senior (Без dangerouslySetInnerHTML)
А тепер - найважливіша частина. Витягнувши контент із бази, ми проганяємо його через нашу серверну утиліту getHTMLFromTiptap() (з Кроку 2). Але як вивести цей HTML на сторінці?
Новачки часто використовують dangerouslySetInnerHTML. Це погано з двох причин:
Ви відкриваєте двері для XSS-уразливостей.
Внутрішні посилання (тег
<a>) спричинятимуть повне перезавантаження сторінки, ігноруючи SPA-навігацію Next.js.
Ми використаємо пакет html-react-parser. Він дозволяє нам перехоплювати HTML-вузли "на льоту" і замінювати їх на React-компоненти. Наприклад, ми замінимо звичайні посилання на компонент Link з next/link, а блоки коду <pre><code> віддамо нашому кастомному компоненту для підсвітки!
Ось як виглядає рендеринг статті на фронті:
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' // Ваш кастомний компонент підсвітки коду
// Наприклад, такий об'єкт приходить з вашої бази даних
interface BlogData {
title: string
content: string // JSON-рядок, який ми зберегли раніше
}
export default function BlogDetail({ blog }: { blog: BlogData }) {
// 🟢 1. Перетворюємо JSON з бази на HTML-рядок
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. Парсимо HTML і підміняємо вузли на React-компоненти */}
{parse(htmlContent, {
replace: (domNode) => {
// Перехоплюємо всі посилання
if (domNode instanceof Element && domNode.name === 'a') {
const href = domNode.attribs.href || '#'
const isExternal = href.startsWith('http')
// Використовуємо Next.js Link замість звичайного <a>
return (
<Link href={href} target={isExternal ? '_blank' : undefined}>
{domToReact(domNode.children as DOMNode[])}
</Link>
)
}
// Перехоплюємо блоки коду для красивої підсвітки
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-', '')
// Віддаємо чистий код у наш кастомний Highlighter
return <CodeBlockWrapper code={codeContent} language={language} />
}
return undefined
},
})}
</div>
</article>
)
}
Крок 6: Магія CSS - стилізуємо адмінку та фронтенд
Один із найбільших плюсів Tiptap - він не тягне свої базові стилі. Ваш редактор і ваш опублікований контент будуть виглядати рівно так, як ви напишете в CSS.
Щоб полегшити вам життя, я зібрав готовий CSS-файл, який вирішує одразу кілька задач:
Робить інтерфейс адмінки чистим та зрозумілим.
Стилізує складні елементи (таблиці, цитати, код) для фронтенду.
Містить "хак" для красивого виводу блоку "Key Takeaways" (Головні висновки), склеюючи заголовок
<h4>та наступний за ним список<ul>в єдину безшовну картку.
Просто створіть файл tiptap.css (або додайте це у ваш глобальний globals.css):
CSS
/* =========================================
Tiptap Editor: Адмін панель
========================================= */
/* 1. Оболонка редактора */
.editor-wrapper {
display: flex;
flex-direction: column;
border: 1px solid var(--Stroke, #e2e8f0);
border-radius: 12px;
background: transparent;
margin-bottom: 20px;
}
.editor-wrapper:focus-within {
border-color: var(--Secondary, #2563eb);
}
/* 2. Тулбар */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 10px 14px;
gap: 5px;
border-bottom: 1px solid var(--Stroke, #e2e8f0);
background: transparent;
}
.toolbar .group {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar .group.ml-auto {
margin-left: auto;
}
.toolbar .divider {
width: 1px;
height: 24px;
background: var(--Stroke, #e2e8f0);
margin: 0 8px;
}
/* 3. Кнопки тулбара */
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
border: none;
background: transparent;
cursor: pointer;
transition: all 0.2s ease;
color: var(--Main-Dark, #1e293b) !important;
}
.toolbar-btn svg {
stroke: currentColor !important;
}
.toolbar-btn:hover {
background-color: var(--Stroke, #f1f5f9);
}
.toolbar-btn.active {
color: var(--Secondary, #2563eb) !important;
background-color: rgba(37, 99, 235, 0.1);
}
/* 4. Поле вводу ProseMirror (База) */
.editor-content {
flex-grow: 1;
padding: 20px;
background: transparent;
}
.ProseMirror {
outline: none;
min-height: 400px;
font-size: 16px;
line-height: 1.6;
color: var(--body-color, #334155);
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #94a3b8;
pointer-events: none;
height: 0;
}
/* =======================================================
ФРОНТЕНД: СТИЛІ ДЛЯ ВИВОДУ КОНТЕНТУ (.tiptap-content)
======================================================= */
/* Блоки коду та Inline код */
.tiptap-content pre {
background: #1e1e1e !important;
color: #d4d4d4 !important;
padding: 16px !important;
border-radius: 8px !important;
font-family: 'Fira Code', monospace !important;
font-size: 14px !important;
overflow-x: auto !important;
margin: 16px 0 !important;
border: 1px solid #333;
}
.tiptap-content code {
background: #f0f0f0;
color: #d63384;
padding: 2px 5px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
/* Таблиці з ефектом зебри */
.tiptap-content table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 24px 0;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.2);
}
.tiptap-content table th,
.tiptap-content table td {
padding: 14px 16px;
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
border-right: 1px solid rgba(128, 128, 128, 0.2);
text-align: left;
font-size: 15px;
color: var(--body-color, #334155);
}
.tiptap-content table th:last-child,
.tiptap-content table td:last-child {
border-right: none;
}
.tiptap-content table tr:last-child td {
border-bottom: none;
}
.tiptap-content table th {
background-color: rgba(128, 128, 128, 0.15);
font-weight: 600;
}
.tiptap-content table tbody tr:nth-child(odd) td {
background-color: #edf2ff;
}
.tiptap-content table tbody tr:nth-child(even) td {
background-color: #dbe4ff;
}
.tiptap-content table tbody tr:hover td {
background-color: #bac8ff;
transition: background-color 0.2s ease;
}
/* Цитати (Блок Вердикту) */
.tiptap-content blockquote {
position: relative;
background-color: rgba(35, 119, 252, 0.07);
border-left: 4px solid var(--Secondary, #2377FC);
border-radius: 8px;
padding: 20px 20px 20px 65px;
margin: 24px 0;
z-index: 1;
}
.tiptap-content blockquote::before {
content: "\f10d"; /* Іконка з LineAwesome */
font-family: "Line Awesome Free", "LineAwesome";
font-weight: 900;
position: absolute;
left: 20px;
top: 22px;
font-size: 28px;
color: var(--Secondary, #2377FC);
}
.tiptap-content blockquote p {
margin: 0 !important;
font-size: 15px;
line-height: 1.6;
font-style: italic;
font-weight: 500;
}
/* =======================================================
СЕКРЕТНИЙ ХАК: Безшовна картка "Key Takeaways" (h4 + ul)
======================================================= */
/* 1. Верхня частина картки (Заголовок h4) */
.tiptap-content h4 {
background-color: rgba(245, 158, 11, 0.07); /* Легкий янтарний фон */
border-left: 4px solid #f59e0b;
border-radius: 8px 8px 0 0; /* Округлюємо ТІЛЬКИ верхні кути */
padding: 16px 20px 8px 20px;
margin: 32px 0 0 0 !important; /* Прибираємо відступ знизу */
font-size: 17px;
font-weight: 700;
}
/* 2. Нижня частина картки (Список ul, що йде відразу за h4) */
.tiptap-content h4 + ul {
background-color: rgba(245, 158, 11, 0.07);
border-left: 4px solid #f59e0b;
border-radius: 0 0 8px 8px; /* Округлюємо ТІЛЬКИ нижні кути */
padding: 0 20px 20px 45px;
margin: 0 0 32px 0 !important; /* Прибираємо відступ зверху */
}
.tiptap-content h4 + ul li {
margin-bottom: 8px;
}
.tiptap-content h4 + ul li:last-child {
margin-bottom: 0;
}
.tiptap-content h4 + ul li p {
margin: 0 !important;
font-size: 15px;
line-height: 1.6;
font-weight: 500;
}
Чому цей CSS такий крутий?
Зверніть увагу на блок "Key Takeaways". В адмінці ви просто створюєте заголовок H4, а одразу під ним робите маркований список. CSS за допомогою селектора суміжності h4 + ul автоматично "склеїть" їх у єдину красиву картку з кольоровим фоном та акцентною лінією зліва.
Вам не потрібно писати кастомні плагіни для Tiptap, щоб створювати інформаційні панелі - правильний CSS вирішує це завдання простіше і витонченіше!
Висновок: Tiptap - це інвестиція у стабільність
Інтеграція Tiptap у проект на Next.js 16 вимагає трохи більше коду на старті, ніж підключення умовного Quill або Draft.js. Але ця інвестиція окупається миттєво.
Ви отримуєте:
Чистий React/TypeScript код без "чорних ящиків".
Блискавичний SSR-рендеринг, бо ми конвертуємо JSON у HTML на сервері.
Ідеальне SEO, адже Google отримує чистий і семантичний HTML без клієнтського рендерингу редактора.
Абсолютний контроль над дизайном завдяки Tailwind/CSS та пакету
html-react-parser.
Сподіваюся, цей гайд зекономить вам години дебагінгу. Беріть цей код, адаптуйте під свої SaaS-проєкти чи блоги, і пишіть контент із задоволенням!
(Підготовлено для розробників, які цінують чистий код та надійну архітектуру).

Коментарі (0)
Залишити коментар