Ідеальний Rich Text Editor у Next.js: SSR, SEO та кастомний UI з Tiptap

Автор: Muntai

Ідеальний Rich Text Editor у Next.js: SSR, SEO та кастомний UI з Tiptap

Ідеальний 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, яка виконуватиме дві важливі функції:

  1. Перетворюватиме JSON на HTML для фронтенду.

  2. Витягуватиме чистий текст (без тегів) для генерації 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. Це погано з двох причин:

  1. Ви відкриваєте двері для XSS-уразливостей.

  2. Внутрішні посилання (тег <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-файл, який вирішує одразу кілька задач:

  1. Робить інтерфейс адмінки чистим та зрозумілим.

  2. Стилізує складні елементи (таблиці, цитати, код) для фронтенду.

  3. Містить "хак" для красивого виводу блоку "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. Але ця інвестиція окупається миттєво.

Ви отримуєте:

  1. Чистий React/TypeScript код без "чорних ящиків".

  2. Блискавичний SSR-рендеринг, бо ми конвертуємо JSON у HTML на сервері.

  3. Ідеальне SEO, адже Google отримує чистий і семантичний HTML без клієнтського рендерингу редактора.

  4. Абсолютний контроль над дизайном завдяки Tailwind/CSS та пакету html-react-parser.

Сподіваюся, цей гайд зекономить вам години дебагінгу. Беріть цей код, адаптуйте під свої SaaS-проєкти чи блоги, і пишіть контент із задоволенням!

(Підготовлено для розробників, які цінують чистий код та надійну архітектуру).

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

Залишити коментар