Back to blog

Tiptap in Next.js: Editable Admin Panel, Read-Only Public Render

July 25, 20264 min read
Tiptap in Next.js: Editable Admin Panel, Read-Only Public Render

Every post and project description on this site — including the one you're reading — is stored as Tiptap JSON, not HTML or Markdown. That one decision drives the whole setup: one editor config for the admin panel, one shared extension list that both the editor and the public page import, and a strict rule that they never drift apart.

Why JSON instead of HTML or Markdown

  • Structured and queryable — Prisma stores it as a plain Json column, no separate HTML sanitization pass needed before it's safe to persist.

  • Round-trips losslessly — the editor can load a post back exactly as it was saved, because it's reading the same node tree it wrote.

  • Rendered once, on the server — the public blog page calls Tiptap's generateHTML() against the same extension list, so there's no client-side editor shipped just to display an article.

1. One shared extension list

The single most important file in this setup isn't the editor component — it's the extension list both sides import. If the editor supports a node the renderer doesn't know about, that content silently disappears from the public page.

// src/lib/tiptap-extensions.ts
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 TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { createLowlight, common } from "lowlight";

const lowlight = createLowlight(common);
lowlight.registerAlias({ typescript: ["ts", "tsx"], javascript: ["js", "jsx"], bash: ["sh", "shell"] });

// IMPORTANT: keep this identical wherever Tiptap is used — the editor
// (client) and generateHTML (server) must import the exact same array.
export const tiptapExtensions = [
  StarterKit.configure({
    heading: { levels: [2, 3, 4] },
    codeBlock: false, // disabled here so CodeBlockLowlight (with syntax highlighting) owns it instead
  }),
  CodeBlockLowlight.configure({ lowlight }),
  Link.configure({
    openOnClick: false,
    autolink: true,
    HTMLAttributes: { class: "text-primary underline underline-offset-4" },
  }),
  Image.configure({ inline: false, allowBase64: false }),
  Table.configure({ resizable: false }),
  TableRow,
  TableHeader,
  TableCell,
];

2. The editable side (admin panel, Client Component)

"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import { tiptapExtensions } from "@/lib/tiptap-extensions";
import type { JSONContent } from "@tiptap/core";

export function TiptapEditor({
  content,
  onChange,
}: {
  content: JSONContent;
  onChange: (json: JSONContent) => void;
}) {
  const editor = useEditor({
    extensions: tiptapExtensions,
    content,
    immediatelyRender: false, // avoids a hydration mismatch — Tiptap warns loudly about this in Next.js
    onUpdate: ({ editor }) => onChange(editor.getJSON()),
  });

  return <EditorContent editor={editor} className="prose dark:prose-invert max-w-none" />;
}

The submit handler for this form (a Server Action, following the same pattern as every other form on this site) stringifies editor.getJSON() straight into the Post.contentEn / contentFa Prisma columns — no HTML conversion at this stage.

3. The read-only side (public blog page, Server Component)

The public page never mounts a Tiptap editor at all. It reads the stored JSON straight from the database and converts it to HTML on the server with generateHTML() — zero editor JavaScript ships to a reader.

// src/app/[locale]/blog/[slug]/page.tsx
import { generateHTML } from "@tiptap/html";
import { tiptapExtensions } from "@/lib/tiptap-extensions";
import { postRepository } from "@/lib/repositories/post-repository";

export default async function BlogPostPage({ params }: { params: { slug: string; locale: "en" | "fa" } }) {
  const post = await postRepository.findBySlug(params.slug);
  const json = params.locale === "fa" ? post.contentFa : post.contentEn;

  // generateHTML runs synchronously on the server, using the exact same
  // extension list as the editor — this is what keeps rendering consistent.
  const html = generateHTML(json as object, tiptapExtensions);

  return (
    <article
      className="prose dark:prose-invert max-w-none"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

generateHTML() output still goes through dangerouslySetInnerHTML, so it's exactly as trusted as any other HTML you inject that way. Since only an authenticated admin can ever write this content (there's no public submission form), that's an acceptable trade — it would not be if this JSON ever came from an untrusted user.

4. Syntax-highlighted code blocks

CodeBlockLowlight pairs the codeBlock node with lowlight (a syntax highlighter built on highlight.js's grammars) so code inside a post gets real syntax highlighting on both the editor and the rendered page, using the same lowlight instance and language aliases in both places.

5. Images — uploaded, not pasted as base64

Image.configure({ allowBase64: false }) is deliberate: pasting a screenshot into the editor should upload it (to Vercel Blob, in this project) and insert a real URL, not balloon the JSON column with a multi-megabyte base64 string. The editor's image button handles the upload and calls editor.chain().setImage({ src: url }) once it has a URL back.

Checklist

  1. One extension list, imported by both the editor and generateHTML — never define them separately.

  2. immediatelyRender: false on useEditor to avoid SSR/hydration warnings in Next.js.

  3. Store raw JSON in the database, convert to HTML only at render time, on the server.

  4. allowBase64: false on the image extension — upload to storage and insert a URL instead.

  5. Treat generateHTML output as trusted only because the content is admin-authored — never do this for user-submitted content without sanitizing first.