Building a Clean Rich Text Editor with Tiptap in Next.js (Real Project Setup)

Published: (December 31, 2025 at 11:42 PM EST)
5 min read
Source: Dev.to

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 outputonChange returns 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

  • Toggle component – a reusable button that receives onPressedChange and a pressed state.
  • 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.
Back to Blog

Related posts

Read more »

Fix Hydration Errors in Next.js

Common Causes of Hydration Errors Browser/Environment Issues - Browser extensions injecting attributes password managers, ad blockers, accessibility tools - Br...