Next.js에서 Tiptap으로 깨끗한 Rich Text Editor 구축 (실제 프로젝트 설정)

발행: (2026년 1월 1일 오후 01:42 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

대시보드나 SaaS 앱을 만들다 보면 어느 순간 실제 텍스트 편집기가 필요합니다 — 단순한 textarea도, 가짜 마크다운 박스도 아닙니다.
필요한 기능은 다음과 같습니다:

  • 헤딩
  • 리스트
  • 포맷팅
  • 깔끔한 HTML 출력
  • UI에 대한 완전한 제어

바로 여기서 Tiptap이 완벽하게 맞아떨어집니다.
아래 블로그 포스트에서는 Next.js + React 대시보드에서 Tiptap 편집기를 어떻게 사용하는지, 커스텀 툴바, Tailwind 스타일링, 그리고 데이터베이스에 저장할 수 있는 HTML 출력까지 보여줍니다.

Note: This is real dashboard code, not a demo.

1️⃣ 대시보드 페이지 (ResumesPage)

"use client";

import DashboardLayout from "@/components/(user-dashboard)/layout/DashboardLayout";
import DiagonalDivider from "@/components/(user-dashboard)/components/DiagonalDivider";
import { useState } from "react";
import TipTapEditorMinimal from "@/components/(editors)/RichTextEditor/TipTapEditorMinimal";
import PrimaryButton from "@/components/Common/ui/PrimaryButton";
import { FaFile } from "react-icons/fa";
import { FileChartColumn } from "lucide-react";

export default function ResumesPage() {
  const [resumeHtml, setResumeHtml] = useState("");
  const [resumeJson, setResumeJson] = useState(null);

  const handleResumeChange = (html: string, json: any) => {
    setResumeHtml(html);
    setResumeJson(json);
    // Save to your Database via API route
  };

  const [post, setPost] = useState("");

  const onChange = (content: string) => {
    setPost(content);
  };

  const initialTemplate = `
    

Professional Summary

MERN 스택, Next.js, Tailwind CSS에 전문성을 갖춘 풀스택 개발자.

경험

  • XYZ Corp에서 소프트웨어 엔지니어 (2023‑현재)
  `;

  return (
    <>
      {/* Header */}
      <div>
        <h2>Resumes</h2>
        <p>Track all your resume download activities</p>
      </div>

      {/* Resume Builder */}
      <section>
        <h2>Resume Builder</h2>
        <p>{resumeHtml.length} characters</p>
        <PrimaryButton
          onClick={() => {
            console.log("Save resume:", {
              html: resumeHtml,
              json: resumeJson,
            });
          }}
        >
          Save Resume
        </PrimaryButton>
        <TipTapEditorMinimal
          content={initialTemplate}
          onChange={handleResumeChange}
        />
      </section>
    </>
  );
}

이 컴포넌트가 하는 일

  • Editor setup – 최소 Tiptap 편집기를 마운트합니다.
  • Extensions – 스타터 키트, 텍스트‑정렬, 하이라이트 확장을 추가합니다.
  • HTML outputonChange는 편집기의 HTML을 반환합니다 (DB 저장 준비 완료).
  • Tailwind styling – 모든 것이 Tailwind 클래스들로 스타일링됩니다.

2️⃣ Minimal Tiptap Editor (TipTapEditorMinimal)

"use client";

import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
import TextAlign from "@tiptap/extension-text-align";
import Highlight from "@tiptap/extension-highlight";
import MenuBar from "./MenuBar";

interface RichTextEditorProps {
  content: string;
  onChange: (content: string) => void;
}

export default function TipTapEditorMinimal({
  content,
  onChange,
}: RichTextEditorProps) {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        bulletList: {
          HTMLAttributes: {
            class: "list-disc ml-4 my-2",
          },
        },
        orderedList: {
          HTMLAttributes: {
            class: "list-decimal ml-4 my-2",
          },
        },
      }),
      TextAlign.configure({
        types: ["heading", "paragraph"],
      }),
      Highlight,
    ],
    content,
    editorProps: {
      attributes: {
        class:
          "h-[230px] overflow-y-auto p-4 border rounded-b-md outline-none prose",
      },
    },
    onUpdate: ({ editor }) => {
      onChange(editor.getHTML());
    },
    immediatelyRender: false,
  });

  return (
    <>
      {editor && <MenuBar editor={editor} />}
      {editor && <EditorContent editor={editor} />}
    </>
  );
}

이 툴바는 완전히 커스텀이며 — 기본 Tiptap UI가 없습니다.
지원 기능:

  • 헤딩
  • 굵게 / 기울임 / 취소선
  • 정렬
  • 목록
  • 하이라이트

3️⃣ 사용자 정의 툴바 (MenuBar)

import React, { useEffect, useState } from "react";
import {
  AlignCenter,
  AlignLeft,
  AlignRight,
  Bold,
  Heading1,
  Heading2,
  Heading3,
  Highlighter,
  Italic,
  List,
  ListOrdered,
  Strikethrough,
} from "lucide-react";
import { Editor } from "@tiptap/react";
import { Toggle } from "../../Common/ui/Toggle";

export default function MenuBar({ editor }: { editor: Editor | null }) {
  const [, forceUpdate] = useState({});

  // Re‑render on selection/transaction changes so button states stay in sync
  useEffect(() => {
    if (!editor) return;
    const updateHandler = () => forceUpdate({});
    editor.on("selectionUpdate", updateHandler);
    editor.on("transaction", updateHandler);
    return () => {
      editor.off("selectionUpdate", updateHandler);
      editor.off("transaction", updateHandler);
    };
  }, [editor]);

  if (!editor) return null;

  return (
    <>
      {/* Bold */}
      <Toggle
        onPressedChange={() => editor.chain().focus().toggleBold().run()}
        pressed={editor.isActive("bold")}
      >
        <Bold />
      </Toggle>

      {/* Italic */}
      <Toggle
        onPressedChange={() => editor.chain().focus().toggleItalic().run()}
        pressed={editor.isActive("italic")}
      >
        <Italic />
      </Toggle>

      {/* Strikethrough */}
      <Toggle
        onPressedChange={() => editor.chain().focus().toggleStrike().run()}
        pressed={editor.isActive("strike")}
      >
        <Strikethrough />
      </Toggle>

      {/* Heading 1 */}
      <Toggle
        onPressedChange={() =>
          editor.chain().focus().toggleHeading({ level: 1 }).run()
        }
        pressed={editor.isActive("heading", { level: 1 })}
      >
        <Heading1 />
      </Toggle>

      {/* Heading 2 */}
      <Toggle
        onPressedChange={() =>
          editor.chain().focus().toggleHeading({ level: 2 }).run()
        }
        pressed={editor.isActive("heading", { level: 2 })}
      >
        <Heading2 />
      </Toggle>

      {/* Heading 3 */}
      <Toggle
        onPressedChange={() =>
          editor.chain().focus().toggleHeading({ level: 3 }).run()
        }
        pressed={editor.isActive("heading", { level: 3 })}
      >
        <Heading3 />
      </Toggle>

      {/* Unordered List */}
      <Toggle
        onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
        pressed={editor.isActive("bulletList")}
      >
        <List />
      </Toggle>

      {/* Ordered List */}
      <Toggle
        onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
        pressed={editor.isActive("orderedList")}
      >
        <ListOrdered />
      </Toggle>

      {/* Text Align Left */}
      <Toggle
        onPressedChange={() => editor.chain().focus().setTextAlign("left").run()}
        pressed={editor.isActive({ textAlign: "left" })}
      >
        <AlignLeft />
      </Toggle>

      {/* Text Align Center */}
      <Toggle
        onPressedChange={() =>
          editor.chain().focus().setTextAlign("center").run()
        }
        pressed={editor.isActive({ textAlign: "center" })}
      >
        <AlignCenter />
      </Toggle>

      {/* Text Align Right */}
      <Toggle
        onPressedChange={() => editor.chain().focus().setTextAlign("right").run()}
        pressed={editor.isActive({ textAlign: "right" })}
      >
        <AlignRight />
      </Toggle>

      {/* Highlight */}
      <Toggle
        onPressedChange={() => editor.chain().focus().toggleHighlight().run()}
        pressed={editor.isActive("highlight")}
      >
        <Highlighter />
      </Toggle>
    </>
  );
}

툴바 작동 방식

  • Toggle componentonPressedChangepressed 상태를 받는 재사용 가능한 버튼.
  • editor.chain().focus().().run() – 에디터에 포커스를 유지하면서 Tiptap 명령을 실행합니다.
  • editor.isActive(...) – 현재 선택과 UI를 동기화합니다 (예: 굵게).

버튼은 커서가 굵은 텍스트 안에 있을 때도 강조된 상태를 유지합니다).

📦 Summary

  • Tiptap은 완전한 기능을 갖춘 확장 가능한 리치 텍스트 편집기를 제공합니다.
  • Next.js 클라이언트 컴포넌트에 연결하면 깨끗한 HTML(또는 JSON)을 캡처하여 데이터베이스에 바로 저장할 수 있습니다.
  • Tailwind와 Lucide 아이콘으로 만든 맞춤 툴바는 필요한 정확한 UI를 제공하며, 불필요한 부피가 없습니다.

코드를 자유롭게 복사하고, 스타일을 조정하며, 편집기를 더 많은 확장(테이블, 이미지, 멘션 등)으로 확장하세요. 제품이 성장함에 따라. 행복한 코딩 되세요!

컴포넌트 사용법

editor.chain().focus().toggleItalic().run();
editor.chain().focus().toggleStrike().run();
editor.chain().focus().toggleBulletList().run();
editor.chain().focus().toggleOrderedList().run();
editor.chain().focus().toggleHighlight().run();

토글 컴포넌트 (Toggle.tsx)

"use client";

import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva } from "class-variance-authority";
import { cn } from "@/utils/editorUtils";

const toggleVariants = cva(
  "inline-flex items-center justify-center rounded-md transition",
  {
    variants: {
      size: {
        default: "h-9 w-9",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
);

function Toggle({
  className,
  ...props
}: React.ComponentProps) {
  return (
    <TogglePrimitive.Root
      className={cn(toggleVariants({ size: "default" }), className)}
      {...props}
    />
  );
}

export { Toggle };

유틸리티 함수 (editorUtils.ts)

import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: any[]) {
  return twMerge(clsx(inputs));
}

왜 Tiptap을 선택해야 할까요?

  • 자유 – 강제 UI가 없습니다.
  • 제어 – 마크업, 툴바, 출력, 성능을 직접 결정합니다.
  • 다재다능 – 대시보드, SaaS 편집기, 콘텐츠 빌더에 완벽합니다.
  • 신뢰성 – 실제 프로젝트에서 깔끔하게 작동합니다—마법 없이, 견고한 React 코드만 있습니다.
Back to Blog

관련 글

더 보기 »

Next.js에서 Hydration 오류 수정

수화 오류의 일반적인 원인 브라우저/환경 문제 - 속성을 주입하는 브라우저 확장 프로그램, password managers, ad blockers, accessibility tools - Br...