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.
