TipTap v3 with Next.js 15: Build a Production Rich Text Editor Tutorial

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

TipTap v3 is a headless, framework-agnostic rich text editor built on top of ProseMirror. Unlike traditional editors that ship with heavy default UI, TipTap gives you complete control over markup, commands, and styling. This makes it the editor of choice for modern SaaS products like Linear, Gitbook, Notion-like applications, and content management systems.

In this tutorial, you will build a fully featured rich text editor in Next.js 15 using TipTap v3. By the end, you will have a production-ready editor with formatting controls, image uploads, tables, and the ability to persist content to a database as structured JSON.

Prerequisites

Before starting, ensure you have:

  • Node.js 20 or newer installed
  • Basic familiarity with Next.js App Router
  • Working knowledge of React and TypeScript
  • A code editor such as VS Code
  • npm, pnpm, or bun package manager

What You Will Build

You will create a rich text editor that supports:

  • Text formatting: bold, italic, underline, strikethrough, code
  • Headings (H1 through H3), bullet and ordered lists, blockquotes
  • Links with popover editor
  • Inline images with upload handling
  • Tables with resizable columns
  • A sticky formatting toolbar styled with Tailwind CSS
  • Persistence of editor content as JSON to a backend API
  • Rendering saved content back as HTML on a read-only page

Step 1: Create a New Next.js 15 Project

Start by generating a fresh Next.js project with the App Router and TypeScript.

npx create-next-app@latest tiptap-editor --typescript --tailwind --app --src-dir=false --import-alias "@/*"
cd tiptap-editor

When prompted, choose these options:

  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Yes
  • App Router: Yes
  • Turbopack: Yes

Step 2: Install TipTap v3 and Extensions

Install the core TipTap packages along with the starter kit and the extensions we will use.

npm install @tiptap/react @tiptap/starter-kit @tiptap/pm
npm install @tiptap/extension-link @tiptap/extension-image
npm install @tiptap/extension-placeholder @tiptap/extension-table
npm install @tiptap/extension-table-row @tiptap/extension-table-cell @tiptap/extension-table-header

The @tiptap/starter-kit bundles commonly used extensions such as paragraphs, bold, italic, headings, lists, and history. Additional extensions are installed individually so you can include only what you need and keep the bundle small.

Step 3: Create the Editor Component

Create a new file components/editor/Editor.tsx that wraps TipTap and exposes a clean React interface.

"use client";
 
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import Toolbar from "./Toolbar";
 
type EditorProps = {
  content?: string;
  onChange?: (html: string, json: unknown) => void;
  editable?: boolean;
};
 
export default function Editor({ content = "", onChange, editable = true }: EditorProps) {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        heading: { levels: [1, 2, 3] },
      }),
      Link.configure({
        openOnClick: false,
        autolink: true,
        HTMLAttributes: { class: "text-blue-600 underline" },
      }),
      Image.configure({ inline: false, allowBase64: false }),
      Placeholder.configure({
        placeholder: "Start writing your story...",
      }),
      Table.configure({ resizable: true }),
      TableRow,
      TableHeader,
      TableCell,
    ],
    content,
    editable,
    immediatelyRender: false,
    editorProps: {
      attributes: {
        class: "prose prose-slate max-w-none focus:outline-none min-h-[320px] p-4",
      },
    },
    onUpdate: ({ editor }) => {
      onChange?.(editor.getHTML(), editor.getJSON());
    },
  });
 
  if (!editor) return null;
 
  return (
    <div className="border rounded-lg bg-white shadow-sm">
      {editable && <Toolbar editor={editor} />}
      <EditorContent editor={editor} />
    </div>
  );
}

Two important details:

  • immediatelyRender: false is required in Next.js 15 to avoid hydration mismatches because TipTap renders on the client.
  • The prose class from Tailwind Typography gives elegant defaults for headings, lists, and blockquotes with zero extra configuration.

Step 4: Install Tailwind Typography

TipTap ships without any styling. Tailwind Typography provides a beautiful reading experience out of the box.

npm install -D @tailwindcss/typography

Add the plugin to your Tailwind config. In Tailwind v4 with Next.js 15, append the following directive inside app/globals.css:

@import "tailwindcss";
@plugin "@tailwindcss/typography";

Step 5: Build the Toolbar Component

Create components/editor/Toolbar.tsx with formatting buttons. Each button uses editor.chain() to apply commands.

"use client";
 
import type { Editor } from "@tiptap/react";
import {
  Bold, Italic, Strikethrough, Code, Heading1, Heading2, Heading3,
  List, ListOrdered, Quote, Undo, Redo, Link as LinkIcon, Image as ImgIcon,
  Table as TableIcon,
} from "lucide-react";
 
type Props = { editor: Editor };
 
function ToolbarButton({
  onClick,
  active,
  disabled,
  children,
  title,
}: {
  onClick: () => void;
  active?: boolean;
  disabled?: boolean;
  children: React.ReactNode;
  title: string;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      title={title}
      className={`p-2 rounded hover:bg-slate-100 transition ${
        active ? "bg-slate-200 text-slate-900" : "text-slate-600"
      } disabled:opacity-40`}
    >
      {children}
    </button>
  );
}
 
export default function Toolbar({ editor }: Props) {
  const addLink = () => {
    const url = window.prompt("Enter URL");
    if (url === null) return;
    if (url === "") {
      editor.chain().focus().unsetLink().run();
      return;
    }
    editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
  };
 
  const addImage = () => {
    const url = window.prompt("Image URL");
    if (url) editor.chain().focus().setImage({ src: url }).run();
  };
 
  const addTable = () => {
    editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
  };
 
  return (
    <div className="flex flex-wrap gap-1 p-2 border-b bg-slate-50 sticky top-0 z-10">
      <ToolbarButton title="Bold" onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")}>
        <Bold size={16} />
      </ToolbarButton>
      <ToolbarButton title="Italic" onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")}>
        <Italic size={16} />
      </ToolbarButton>
      <ToolbarButton title="Strike" onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")}>
        <Strikethrough size={16} />
      </ToolbarButton>
      <ToolbarButton title="Code" onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive("code")}>
        <Code size={16} />
      </ToolbarButton>
      <span className="w-px bg-slate-300 mx-1" />
      <ToolbarButton title="H1" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive("heading", { level: 1 })}>
        <Heading1 size={16} />
      </ToolbarButton>
      <ToolbarButton title="H2" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive("heading", { level: 2 })}>
        <Heading2 size={16} />
      </ToolbarButton>
      <ToolbarButton title="H3" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive("heading", { level: 3 })}>
        <Heading3 size={16} />
      </ToolbarButton>
      <span className="w-px bg-slate-300 mx-1" />
      <ToolbarButton title="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")}>
        <List size={16} />
      </ToolbarButton>
      <ToolbarButton title="Ordered list" onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")}>
        <ListOrdered size={16} />
      </ToolbarButton>
      <ToolbarButton title="Quote" onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")}>
        <Quote size={16} />
      </ToolbarButton>
      <span className="w-px bg-slate-300 mx-1" />
      <ToolbarButton title="Link" onClick={addLink} active={editor.isActive("link")}>
        <LinkIcon size={16} />
      </ToolbarButton>
      <ToolbarButton title="Image" onClick={addImage}>
        <ImgIcon size={16} />
      </ToolbarButton>
      <ToolbarButton title="Table" onClick={addTable}>
        <TableIcon size={16} />
      </ToolbarButton>
      <span className="w-px bg-slate-300 mx-1" />
      <ToolbarButton title="Undo" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}>
        <Undo size={16} />
      </ToolbarButton>
      <ToolbarButton title="Redo" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()}>
        <Redo size={16} />
      </ToolbarButton>
    </div>
  );
}

Install Lucide for icons if you have not already.

npm install lucide-react

Step 6: Add a Page that Uses the Editor

Create app/editor/page.tsx to host the editor and capture its output.

"use client";
 
import { useState } from "react";
import Editor from "@/components/editor/Editor";
 
export default function EditorPage() {
  const [html, setHtml] = useState("");
  const [json, setJson] = useState<unknown>(null);
  const [saving, setSaving] = useState(false);
 
  const save = async () => {
    setSaving(true);
    const res = await fetch("/api/documents", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ html, json }),
    });
    setSaving(false);
    if (!res.ok) alert("Failed to save");
  };
 
  return (
    <main className="max-w-3xl mx-auto p-8 space-y-4">
      <h1 className="text-3xl font-bold">New Document</h1>
      <Editor onChange={(h, j) => { setHtml(h); setJson(j); }} />
      <button
        onClick={save}
        disabled={saving}
        className="px-4 py-2 bg-slate-900 text-white rounded hover:bg-slate-800 disabled:opacity-50"
      >
        {saving ? "Saving..." : "Save"}
      </button>
    </main>
  );
}

Step 7: Create a Save Endpoint

Store documents as JSON. Using JSON rather than HTML future-proofs your data because you can re-render it with a different editor configuration later.

Create app/api/documents/route.ts.

import { NextResponse } from "next/server";
 
type Payload = { html: string; json: unknown };
 
export async function POST(req: Request) {
  const body = (await req.json()) as Payload;
 
  if (!body.json) {
    return NextResponse.json({ error: "Missing json" }, { status: 400 });
  }
 
  // Replace with your database of choice, for example Drizzle or Prisma.
  // await db.insert(documents).values({ html: body.html, json: body.json });
 
  return NextResponse.json({ ok: true });
}

For production, persist json as a JSONB column in Postgres. The html field is convenient for search indexing or read-only rendering but should be treated as a derived cache.

Step 8: Implement Image Uploads

Instead of using a raw URL, let users upload images. Start by adding an upload route at app/api/uploads/route.ts.

import { NextResponse } from "next/server";
import { writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";
 
export async function POST(req: Request) {
  const form = await req.formData();
  const file = form.get("file") as File | null;
  if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
 
  const buffer = Buffer.from(await file.arrayBuffer());
  const ext = path.extname(file.name) || ".png";
  const name = `${randomUUID()}${ext}`;
  const dest = path.join(process.cwd(), "public", "uploads", name);
 
  await writeFile(dest, buffer);
  return NextResponse.json({ url: `/uploads/${name}` });
}

For production, swap the local filesystem writer for an S3 client or Vercel Blob. The local approach works for development only.

Now update the toolbar image button to upload a file instead of prompting for a URL.

const uploadImage = async () => {
  const input = document.createElement("input");
  input.type = "file";
  input.accept = "image/*";
  input.onchange = async () => {
    const file = input.files?.[0];
    if (!file) return;
    const form = new FormData();
    form.append("file", file);
    const res = await fetch("/api/uploads", { method: "POST", body: form });
    const { url } = await res.json();
    editor.chain().focus().setImage({ src: url, alt: file.name }).run();
  };
  input.click();
};

Replace the previous addImage handler with this upload flow. Tiptap will insert the uploaded image as soon as the server responds.

Step 9: Render Saved Content

Create a read-only view at app/documents/[id]/page.tsx that renders stored JSON back into HTML using the same extensions.

import { generateHTML } from "@tiptap/html";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
 
type Params = { params: Promise<{ id: string }> };
 
export default async function DocumentPage({ params }: Params) {
  const { id } = await params;
  const doc = await loadDocumentById(id); // implement against your DB
 
  const html = generateHTML(doc.json, [
    StarterKit,
    Link,
    Image,
    Table,
    TableRow,
    TableHeader,
    TableCell,
  ]);
 
  return (
    <article
      className="prose prose-slate max-w-3xl mx-auto p-8"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

Because generateHTML runs at request time inside a React Server Component, the client never has to download or mount TipTap just to display content. That keeps your read pages fast.

Step 10: Add Collaborative Editing (Optional)

TipTap provides first-class Yjs integration for real-time collaboration. Install the relevant packages.

npm install @tiptap/extension-collaboration yjs y-websocket

Then swap StarterKit history for Yjs history and connect a provider.

import Collaboration from "@tiptap/extension-collaboration";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
 
const ydoc = new Y.Doc();
const provider = new WebsocketProvider("wss://your-ws-server", "room-1", ydoc);
 
const editor = useEditor({
  extensions: [
    StarterKit.configure({ history: false }),
    Collaboration.configure({ document: ydoc }),
  ],
});

For production, host a Yjs server such as Hocuspocus or use TipTap Cloud.

Step 11: Secure HTML Output

If you ever render stored HTML directly with dangerouslySetInnerHTML, always sanitize it server-side. Attackers can inject scripts through copy-paste. Use a library such as isomorphic-dompurify before trusting any HTML.

import DOMPurify from "isomorphic-dompurify";
const safe = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });

Alternatively, always persist and render the JSON representation — it cannot contain raw scripts by definition because TipTap only emits known node types.

Step 12: Bundle Size Optimization

TipTap ships tree-shakable modules, but the full extension set still adds roughly 120 kB gzipped to the client bundle. Reduce this in two ways:

  • Dynamic import the editor component with next/dynamic and ssr: false
  • Only install the extensions you actually use rather than the whole starter kit
import dynamic from "next/dynamic";
 
const Editor = dynamic(() => import("@/components/editor/Editor"), {
  ssr: false,
  loading: () => <div className="h-80 animate-pulse bg-slate-100 rounded-lg" />,
});

Testing Your Implementation

Run the development server and open /editor in the browser.

npm run dev

Verify the following behaviors:

  • Typing creates paragraphs with the placeholder text disappearing
  • Bold, italic, and code marks toggle on the selected text
  • Heading buttons switch the current block type
  • Lists and blockquotes wrap the active paragraph
  • Link button opens a prompt and produces a clickable underlined link
  • Image upload places an image in the document after the server response
  • Inserting a table creates a 3x3 grid with resizable columns
  • Clicking save sends a POST with both html and json
  • Reloading the saved document renders the same content without editor chrome

Troubleshooting

Hydration warnings on mount. Set immediatelyRender: false when calling useEditor. This prevents TipTap from rendering during SSR.

document is not defined during build. Always add "use client" at the top of editor files and dynamically import the editor component on pages that are rendered on the server.

Tables not rendering. Make sure all four table extensions (Table, TableRow, TableHeader, TableCell) are registered both on the client editor and inside generateHTML on the server.

Pasted images turning into huge base64 strings. Configure the Image extension with allowBase64: false and intercept paste events to upload the file first.

Next Steps

Now that you have a working editor, consider extending it further.

  • Add slash commands that pop a menu when the user types /, similar to Notion
  • Implement mentions with @tiptap/extension-mention
  • Integrate AI completions using the Vercel AI SDK alongside the editor, covered in our AI agent with Vercel AI SDK tutorial
  • Support file attachments by extending the Image extension into a generic File node
  • Persist drafts automatically with a debounced autosave hook

Conclusion

TipTap v3 gives you a professional, extensible editor that fits naturally into Next.js 15. You now have a full write-and-render flow with formatting, media, and structured JSON storage. Because the underlying document is not raw HTML, you can safely evolve the schema over time without losing backward compatibility.

For deeper customization, explore building custom nodes with the Node.create() API, which lets you add interactive embeds, diagrams, or domain-specific content types. Rich text is no longer a commodity component — with TipTap, it becomes a core product surface you fully control.


Want to read more tutorials? Check out our latest tutorial on Turso and Drizzle ORM with Next.js: Edge-Ready Databases in 2026.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build a Full-Stack App with Appwrite Cloud and Next.js 15

Learn how to build a complete full-stack application using Appwrite Cloud as your backend-as-a-service and Next.js 15 App Router. Covers authentication, databases, file storage, and real-time features.

30 min read·

Build a Real-Time Full-Stack App with Convex and Next.js 15

Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

30 min read·