Next.js에서 Tiptap으로 깨끗한 Rich Text Editor 구축 (실제 프로젝트 설정)
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 output –
onChange는 편집기의 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>
</>
);
}
툴바 작동 방식
Togglecomponent –onPressedChange와pressed상태를 받는 재사용 가능한 버튼.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 코드만 있습니다.