Building a Clean Rich Text Editor with Tiptap in Next.js (Real Project Setup)
Source: Dev.to
When you build a dashboard or a SaaS app, at some point you need a real text editor — not a simple textarea, not a fake markdown box.
You need:
- headings
- lists
- formatting
- clean HTML output
- full control over the UI
That’s where Tiptap fits perfectly.
The blog post below shows how I’m using the Tiptap editor in a Next.js + React dashboard, with a custom toolbar, Tailwind styling, and HTML output ready to be saved in a database.
Note: This is real dashboard code, not a demo.
1️⃣ Dashboard Page (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
Full-stack developer with expertise in MERN stack, Next.js, and Tailwind CSS.
## Experience
- Software Engineer at XYZ Corp (2023-Present)
`;
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>
</>
);
}
What this component does
- Editor setup – mounts the minimal Tiptap editor.
- Extensions – adds the starter kit, text‑align, and highlight extensions.
- HTML output –
onChangereturns the editor’s HTML (ready for DB storage). - Tailwind styling – everything is styled with Tailwind classes.
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} />}
</>
);
}
This toolbar is fully custom — no default Tiptap UI.
It supports:
- headings
- bold / italic / strike
- alignment
- lists
- highlight
3️⃣ Custom Toolbar (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>
</>
);
}
How the toolbar works
Togglecomponent – a reusable button that receivesonPressedChangeand apressedstate.editor.chain().focus().().run()– runs the Tiptap command while keeping the editor focused.editor.isActive(...)– keeps the UI in sync with the current selection (e.g., bold button stays highlighted when the cursor is inside bold text).
📦 Summary
- Tiptap gives you a fully‑featured, extensible rich‑text editor.
- By wiring it into a Next.js client component, you can capture clean HTML (or JSON) and store it directly in your database.
- A custom toolbar built with Tailwind and Lucide icons provides the exact UI you need—no extra bloat.
Feel free to copy the code, adapt the styling, and extend the editor with more extensions (tables, images, mentions, etc.) as your product grows. Happy coding!
Component Usage
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 Component (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 };
Utility Functions (editorUtils.ts)
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: any[]) {
return twMerge(clsx(inputs));
}
Why Choose Tiptap?
- Freedom – No forced UI.
- Control – You decide the markup, toolbar, output, and performance.
- Versatility – Perfect for dashboards, SaaS editors, and content builders.
- Reliability – Works cleanly in real projects—no magic, just solid React code.