DEV Community

Cover image for Building a Clean Rich Text Editor with Tiptap in Next.js (Real Project Setup)
Rohitash Singh
Rohitash Singh

Posted on

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

When you build a dashboard or a SaaS app, at some point you need a real text editor — not a textarea, not a fake markdown box.

You need:

  • headings
  • lists
  • formatting
  • clean HTML output
  • full control over UI

That’s where Tiptap fits perfectly.

This blog shows how I’m using Tiptap Editor in a Next.js + React dashboard, with a custom toolbar, Tailwind styling, and HTML output ready to save in a database.

This is not a demo.
This is real dashboard code.

The Page Where Editor Is Used

This is a Next.js client component inside a dashboard layout.
The editor is used for building resume-like structured content.

"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 = `
    <h2>Professional Summary</h2>
    <p>Full-stack developer with expertise in MERN stack, Next.js, and Tailwind CSS.</p>

    <h2>Experience</h2>
    <ul>
      <li>Software Engineer at XYZ Corp (2023-Present)</li>
    </ul>
  `;

  return (
    <>
      <DashboardLayout>
        <div className="mx-auto max-w-6xl">
          <div className="rounded-xl border border-gray-300 backdrop-blur-sm dark:border-gray-800">
            <div className="relative rounded-xl bg-white p-6 dark:bg-gray-900">
              <div className="bg-primary absolute top-8 left-0 h-10 w-[4px] -translate-x-1/2 rounded-full" />
              <div className="flex items-center gap-4">
                <FileChartColumn className="text-primary h-10 w-10" />
                <div>
                  <h1 className="text-3xl font-bold">Resumes</h1>
                  <p className="text-gray-600">
                    Track all your resume download activities
                  </p>
                </div>
              </div>
            </div>
          </div>
        </div>

        <div className="mx-auto mt-10 mb-10 max-w-6xl">
          <div className="rounded-xl border border-gray-300 backdrop-blur-sm dark:border-gray-800">
            <div className="relative rounded-xl bg-white p-6 dark:bg-gray-900">
              <h1 className="mb-6 text-3xl font-bold">
                Resume Builder
              </h1>

              <TipTapEditorMinimal content={post} onChange={onChange} />

              <div className="mt-6 rounded-lg border p-4">
                <p>{resumeHtml.length} characters</p>

                <PrimaryButton
                  className="mt-4"
                  onClick={() => {
                    console.log("Save resume:", {
                      html: resumeHtml,
                      json: resumeJson,
                    });
                  }}
                >
                  Save Resume
                </PrimaryButton>
              </div>
            </div>
          </div>
        </div>
      </DashboardLayout>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Minimal Tiptap Editor Component

This component handles:

  • editor setup
  • extensions
  • HTML output
  • Tailwind styling
"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: 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 (
    <div>
      {editor && <MenuBar editor={editor} />}
      {editor && <EditorContent editor={editor} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Custom Toolbar (MenuBar)

This toolbar is fully custom — no default Tiptap UI.

It supports:

  • headings
  • bold / italic / strike
  • alignment
  • lists
  • highlight
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({});

  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 (
    <div className="flex flex-wrap gap-1 border rounded-t-md p-2">
      <Toggle onPressedChange={() => editor.chain().focus().toggleBold().run()}>
        <Bold />
      </Toggle>

      <Toggle onPressedChange={() => editor.chain().focus().toggleItalic().run()}>
        <Italic />
      </Toggle>

      <Toggle onPressedChange={() => editor.chain().focus().toggleStrike().run()}>
        <Strikethrough />
      </Toggle>

      <Toggle onPressedChange={() => editor.chain().focus().toggleBulletList().run()}>
        <List />
      </Toggle>

      <Toggle onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}>
        <ListOrdered />
      </Toggle>

      <Toggle onPressedChange={() => editor.chain().focus().toggleHighlight().run()}>
        <Highlighter />
      </Toggle>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Toggle Component (Radix + Tailwind)

"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<typeof TogglePrimitive.Root>) {
  return (
    <TogglePrimitive.Root
      className={cn(toggleVariants(), className)}
      {...props}
    />
  );
}

export { Toggle };
Enter fullscreen mode Exit fullscreen mode

Utility Function

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

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

Final Thoughts

Tiptap gives you freedom.

No forced UI.
No opinionated styles.
No hacks.

You control:

  • markup
  • toolbar
  • output
  • performance

If you’re building:

  • a dashboard
  • a SaaS editor
  • a content builder

This setup works cleanly and reliably in real projects.

No magic.
Just solid React code.

Top comments (0)