Building a Blog Platform with Tiptap and Real-time Features

This Article Was Last Updated
2026-07-30
Building a Blog Platform with Tiptap and Real-time Features

Why Build Another Blog Platform

Medium is great until you want ownership of your content. WordPress is powerful until you need to update a theme. Ghost is perfect until you realize the hosting costs add up. I wanted a blog platform that was mine — clean, fast, and built around the writing experience I actually wanted.

PrimeTech started as a weekend project and turned into a full-featured platform. The core idea: a Tiptap-powered editor that feels like writing in a text editor, not filling out a form. A reading experience that's distraction-free. And real-time features that don't require a WebSocket server.

Tiptap: The Editor That Gets Out of Your Way

Tiptap is a headless rich text editor built on ProseMirror. "Headless" means it gives you the engine, not the UI — you style everything yourself. This was exactly what I wanted. Most editors come with a toolbar that looks like Microsoft Word. I wanted something closer to Notion — type / for commands, use keyboard shortcuts, and the UI stays invisible until you need it.

The setup starts with defining your document schema. This is the most important decision — it determines what content your editor can hold:

import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";

const editor = new Editor({
  element: document.getElementById("editor"),
  extensions: [
    StarterKit.configure({
      heading: { levels: [2, 3] },
    }),
    CodeBlockLowlight.configure({
      lowlight,
    }),
    Image.configure({
      inline: false,
      allowBase64: true,
    }),
    Link.configure({
      openOnClick: false,
    }),
    Placeholder.configure({
      placeholder: 'Start writing... Use "/" for commands',
    }),
  ],
  content: "<p>Hello World</p>",
});

I kept the schema minimal. Headings are limited to H2 and H3 — the article title is already an H1 in the page layout. Code blocks use lowlight for syntax highlighting. Images are block-level only (no inline images — they break reading flow). And the placeholder text guides new writers.

The / command menu was built with Tiptap's suggestion extension and a custom React component. When you type /, a dropdown appears with available commands:

import { Suggestion } from "@tiptap/suggestion";

const suggestion = Suggestion({
  char: "/",
  items: ({ query }) => {
    return [
      {
        title: "Heading 2",
        command: ({ editor }) =>
          editor.chain().focus().toggleHeading({ level: 2 }).run(),
      },
      {
        title: "Heading 3",
        command: ({ editor }) =>
          editor.chain().focus().toggleHeading({ level: 3 }).run(),
      },
      {
        title: "Code Block",
        command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
      },
      {
        title: "Bullet List",
        command: ({ editor }) =>
          editor.chain().focus().toggleBulletList().run(),
      },
      {
        title: "Quote",
        command: ({ editor }) =>
          editor.chain().focus().toggleBlockquote().run(),
      },
    ].filter((item) => item.title.toLowerCase().includes(query.toLowerCase()));
  },
});

The Collaboration Bug

I initially enabled Tiptap's collaboration extension for potential future multi-author support. Big mistake. The collaboration mode uses operational transforms (OT) to sync changes between clients, but it also affected single-user editing.

The symptom: when typing quickly, characters would occasionally duplicate or the cursor would jump to the beginning of the paragraph. The cause was a conflict between Tiptap's internal OT state and my optimistic UI updates. When the user typed a character, Tiptap would fire a transaction. My code would then read the document state and update the word count. But the word count update triggered a re-render, which Tiptap interpreted as an external change, which triggered another transaction.

The fix was to debounce the cursor sync and disable the collaboration extension entirely for single-user editing:

// Before: reading state on every transaction (causes re-render loop)
editor.on("update", () => {
  setWordCount(editor.storage.characterCount.words());
});

// After: debounced updates, no re-render conflict
const debouncedUpdate = debounce(() => {
  setWordCount(editor.storage.characterCount.words());
}, 300);

editor.on("update", debouncedUpdate);

The deeper lesson: Tiptap's collaboration mode is designed for multi-client sync, not for single-client state management. If you're not building Google Docs, don't enable it.

SSE for Real-Time Comments

I wanted comments to appear in real time — when someone posts a comment, other readers should see it without refreshing. WebSockets are the obvious choice, but they require a persistent connection and a separate server. For a blog platform hosted on Vercel's serverless functions, WebSockets aren't practical.

Server-Sent Events (SSE) was the perfect fit. SSE is a one-way push from server to client over a standard HTTP connection. It works through serverless functions, requires no special infrastructure, and automatically reconnects.

The server side is simple. Each article page opens an SSE connection, and when a new comment arrives (via a separate POST request), the server pushes it to all connected clients:

// Server: SSE endpoint for comments
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const articleId = searchParams.get("articleId");

  const stream = new ReadableStream({
    start(controller) {
      // Store this controller for later broadcast
      const clientId = addClient(articleId, controller);

      // Send heartbeat every 30s to keep connection alive
      const heartbeat = setInterval(() => {
        controller.enqueue(": heartbeat\n\n");
      }, 30000);

      // Clean up on disconnect
      request.signal.addEventListener("abort", () => {
        clearInterval(heartbeat);
        removeClient(articleId, clientId);
      });
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

// Server: broadcast new comment to all connected clients
export async function broadcastComment(articleId: string, comment: Comment) {
  const clients = getClients(articleId);
  const data = `data: ${JSON.stringify(comment)}\n\n`;
  for (const controller of clients) {
    controller.enqueue(data);
  }
}

The client side listens for events and appends new comments to the list:

useEffect(() => {
  const eventSource = new EventSource(
    `/api/comments/stream?articleId=${articleId}`,
  );

  eventSource.onmessage = (event) => {
    const comment = JSON.parse(event.data);
    setComments((prev) => [...prev, comment]);
  };

  eventSource.onerror = () => {
    // SSE automatically reconnects
    console.log("Reconnecting...");
  };

  return () => eventSource.close();
}, [articleId]);

The beauty of SSE is the automatic reconnection. If the connection drops, the browser reconnects automatically. No retry logic, no exponential backoff — the browser handles it.

On-Demand Code Highlighting

The blog targets developers, so code blocks are common. I initially used Prism.js for syntax highlighting, loaded globally on every page. The problem: Prism's core + all language grammars is about 80KB gzipped. For a blog post with 3 code blocks, that's a lot of JavaScript for minimal benefit.

I switched to Shiki, which uses VS Code's TextMate grammars for highlighting. The key innovation: load the grammar only when a code block enters the viewport.

import { createHighlighter } from "shiki";

let highlighter: Awaited<ReturnType<typeof createHighlighter>> | null = null;

async function getHighlighter() {
  if (!highlighter) {
    highlighter = await createHighlighter({
      themes: ["github-dark"],
      langs: ["javascript", "typescript", "rust", "python"],
    });
  }
  return highlighter;
}

// Lazy highlight on intersection
const observer = new IntersectionObserver(
  async (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        const codeBlock = entry.target as HTMLElement;
        const language = codeBlock.dataset.language;
        const code = codeBlock.textContent;

        const hl = await getHighlighter();
        const html = hl.codeToHtml(code!, {
          lang: language!,
          theme: "github-dark",
        });
        codeBlock.innerHTML = html;

        observer.unobserve(codeBlock);
      }
    }
  },
  { rootMargin: "200px" },
);

// Observe all code blocks
document.querySelectorAll("pre code").forEach((block) => {
  observer.observe(block);
});

The result: code blocks are plain <pre><code> elements until they're 200px from the viewport. Then Shiki loads (if not already loaded), highlights the block, and replaces the content. The first code block on a page highlights in about 50ms. Subsequent blocks are instant because the highlighter is already loaded.

This cut the initial page load time by about 80% for code-heavy posts. The Lighthouse score went from 78 to 95.

The Reading Experience

The reading page was designed to be distraction-free. No sidebar, no navigation, no popups. Just the article content, styled with Tailwind Typography for beautiful prose.

The table of contents is generated automatically from the article's headings. Tiptap's editor exposes the document structure, and I extract headings at build time:

// During article save: extract TOC from editor content
function extractTOC(content: JSONContent): TOCItem[] {
  const toc: TOCItem[] = [];
  for (const node of content.content || []) {
    if (node.type === "heading") {
      toc.push({
        level: node.attrs?.level || 2,
        text: node.content?.map((c) => c.text).join("") || "",
        id: slugify(node.content?.map((c) => c.text).join("") || ""),
      });
    }
  }
  return toc;
}

On the reading page, an IntersectionObserver tracks which heading is currently in view and highlights it in the TOC sidebar. This gives readers a sense of where they are in the article without requiring them to scroll back up.

What I'd Change

If I rebuilt PrimeTech today, I'd use Tiptap's collaboration extension properly — with a Yjs backend — for real-time co-editing. The current SSE approach works for comments, but a proper CRDT-based system would enable collaborative writing.

I'd also add image upload with automatic optimization. Currently, writers paste image URLs, which means the images load from external servers. A proper upload flow with Sharp processing would make the platform self-contained.

Results

PrimeTech handles 50+ published articles with sub-second load times. The Tiptap editor is responsive even on slow connections. Comments appear in real time. And the reading experience scores 95+ on Lighthouse because the code highlighting is lazy-loaded.

The biggest win was the on-demand Shiki setup. Going from 80KB of JavaScript on every page to near-zero initial cost changed the perception of the site from "decent" to "fast." Sometimes the best optimization is loading less.