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

Ідеальний 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)

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

Напишіть мені
Будь ласка, заповніть форму нижче, щоб розпочати спілкування зі мною.

Цей сайт захищено reCAPTCHA. Застосовуються Політика конфіденційності та Умови використання Google.