How Did I Build My Blog
Last updated:
Over the past few months, I've been designing and building this blog from scratch. I wanted a space that felt truly mine — not a template, not a hosted platform, but a hand-crafted site with full control over every pixel and interaction.
This post walks through the core stack powering this blog, the trade-offs I made, and some of the interesting details hiding under the hood. If you're building your own blog or evaluating some of these technologies, I hope this is useful.
The core stack
Let's start with a quick overview of the major technologies used:
The React framework for the web. Handles routing, rendering, bundling, and image optimisation out of the box.
The UI library everything is built on. Server Components, hooks, and a component model that scales from a single button to the whole page.
Static types across every file. Catches mistakes at compile time and makes refactoring fearless.
Markdown with superpowers. Every blog post is an MDX file — full React component support right inside prose.
Zero-runtime CSS-in-JS. Compiles styled components to plain CSS modules at build time — no style injection, no hydration cost.
Sass modules for global typography, animation keyframes, and design abstractions that live outside the component tree.
Physics-based animations for layout transitions, hover effects, and the mobile navigation drawer.
Spring-physics animation library. Powers the booping icons and hover micro-interactions with natural, interruptible motion instead of fixed-duration easing.
Syntax highlighter powering every code block. Uses real TextMate grammars and VS Code themes to colour code at build time — no client-side highlighting.
Live, in-browser code playgrounds from CodeSandbox. Lets readers run and edit interactive React examples right inside a post.
Deployment and hosting. Zero-config builds, automatic preview URLs per branch, and a global CDN.
Document database for flexible, schema-less data storage. Used for persisting dynamic content with a JSON-native query model.
This list may look like overkill for a personal blog, and a few people have asked me why I didn't just use a CMS or a hosted solution. A few reasons:
- All of my content is written in MDX, which gives me the full power of React inside my posts.
- I already work with Next.js day-to-day, so I wanted to reduce context-switching friction.
- I wanted first-hand experience with the latest React Server Components and the App Router in a real project.
If it wasn't for those constraints, I would have been very tempted by Astro — it's tailor-made for content-heavy sites.
Framework: Next.js with App Router
This blog is built on Next.js, using the modern App Router introduced in Next.js 13+. The App Router is a fundamental reimagining of routing and rendering in React — built around Server Components, streaming, and nested layouts.
The previous generation of Next.js projects used the Pages Router, which was excellent but had limitations around layout composition and server-side logic. The App Router solves many of those problems cleanly.
Key decisions:
- Server Components by default — content pages render entirely on the server; no unnecessary hydration.
- Route Groups — I use
(site)and(internal)groupings to share layouts without affecting URLs. - Nested Layouts — each level of the hierarchy gets its own layout, making the site easy to extend.
- TypeScript throughout — strict mode, no
any, fully typed configuration and components.
Content: MDX
MDX is the most critical part of this stack. Every page you're reading is written in MDX.
If you haven't used it before: MDX is a superset of Markdown that lets you embed React components directly in your content. You get the ergonomics of plain Markdown — no <p> tags, clean heading syntax — plus the full power of React when you need it.
This is essential for the kind of content I want to create. I can drop in interactive demos, styled callouts, custom date components, or anything else I can build as a React component, right in the middle of prose.
-
Why not pure React? I tried it. Every paragraph wrapped in a
<p>, every link as a JSX expression — the writing experience was genuinely awful. MDX solves this cleanly. -
Why not a CMS? I edit MDX files directly in VS Code and commit them as code. It keeps everything in one place, benefits from version control, and removes the overhead of a separate system. The trade-off: I have to redeploy to fix a typo. That's fine with me.
I'm using Next.js's built-in MDX support via @next/mdx, with a custom remark plugin to automatically pull publishedAt and lastModifiedAt dates from git history. No manual frontmatter dates needed.
Styling: Linaria
The entire site is styled with Linaria, a zero-runtime CSS-in-JS library. It offers a styled API identical to styled-components, but instead of injecting styles at runtime it compiles them to static CSS modules at build time. A thin SCSS layer sits underneath for global concerns — resets, CSS custom properties, base layout — things that describe the page as a whole rather than any one component, so they don't belong inside a styled call.
import { styled } from "@linaria/react;
export const PageTitle = styled.h1`
font-size: var(--h1-size);
letter-spacing: 0.5px;
text-shadow: var(--text-shadow-contrast-soft, none);
`;The result: zero JavaScript overhead for styles, full compatibility with React Server Components, and a familiar developer experience.
The integration uses next-with-linaria to wire Linaria into the Next.js build pipeline.
Why not Tailwind? Linaria's styled API was a more natural migration path from what I had before, and I find the explicit named components easier to read and maintain. Tailwind is great — just not the right fit for this project.
Design Tokens & Theming
Rather than hardcoding colors or sizes anywhere, everything flows through CSS custom properties. The site has a layered token system:
- Design tokens — space, type scale, radius, motion timing — defined once at
:root. - Color themes —
light,dark, andsoft-dark— each a complete set of semantic color variables. - Anti-FOUC — a tiny inline
<script>in<head>reads the saved theme fromlocalStorageand appliesdata-themeto<html>synchronously before the first paint, eliminating any flash.
Colors are defined as HSL tuples, making it easy to derive variants (muted, subtle, on-color) with predictable lightness relationships.
Typography
Type carries most of the visual personality of a text-heavy site, so it got real attention. It's set in two faces from the same superfamily, which keeps the prose and code feeling like they belong together:
- Source Sans 3 — the primary reading typeface. Humanist, comfortable for long prose, with a wide weight range that lets headings and body text share one font without feeling monotonous.
- Source Code Pro — a monospace companion from the same family, used for all inline code and code blocks. Its proportions echo Source Sans, so switching between prose and code never feels jarring.
Both are loaded from Google Fonts, with font-display: swap so text is readable immediately rather than blocking on the font download. Every size and weight uses fluid typography via CSS clamp(), scaling smoothly between a minimum and maximum viewport instead of jumping at breakpoints — so a heading is never awkwardly oversized on a phone or undersized on a wide monitor. Line length, line height, and vertical rhythm are all tuned for sustained reading rather than raw density.
Code Highlighting: Shiki
Every code block you see on this site is highlighted by Shiki. Unlike runtime highlighters that ship a tokenizer to the browser, Shiki uses the same TextMate grammars and tokenizer as VS Code, and it runs on the server — the page arrives already coloured, with zero highlighting JavaScript on the client.
The interesting part is theming. I didn't want a stock Shiki theme that ignores my carefully tuned light/dark/soft-dark palettes. So instead of a fixed theme, Shiki is fed a custom theme that maps each TextMate grammar scope directly to one of my CSS variable tokens — --color-code-syntax-keyword, --color-code-syntax-string, and so on, the same tokens defined per theme alongside every other colour.
Shiki accepts CSS variable strings directly as foreground values, so the theme is just a plain mapping — no post-processing or sentinel tricks required:
export const SHIKI_THEME = {
name: "code-syntax-vars",
tokenColors: [
{
scope: ["keyword", "keyword.control", "storage.type"],
settings: { foreground: "var(--color-code-syntax-keyword)" },
},
{
scope: ["string", "string.quoted", "string.template"],
settings: { foreground: "var(--color-code-syntax-string)" },
},
// …one entry per syntax category
],
};The payoff: switch the theme and every code block re-colours instantly, with no re-highlighting and no flash — because the actual colours live in CSS variables the browser re-evaluates on the spot.
Interactive Sandbox
Static code blocks are great for reading, but a couple of posts on this site walk through actual UI behavior — todos toggling, a counter incrementing — and no screenshot can show that convincingly. So instead, some code blocks are a real, editable sandbox running directly in the page, not a picture of one:
Todo List
Add, toggle, and remove todos — TypeScript generics, useState with arrays.
import { useState, useRef, useId } from "react"; import "./shared/playground.css"; import "./styles.css"; interface Todo { id: number; text: string; done: boolean; } const INITIAL_TODOS: Todo[] = [ { id: 1, text: "Build a todo app", done: true }, { id: 2, text: "Add TypeScript types", done: false }, { id: 3, text: "Style it nicely", done: false }, ]; export default function App() { const [todos, setTodos] = useState<Todo[]>(INITIAL_TODOS); const [input, setInput] = useState(""); const inputRef = useRef<HTMLInputElement>(null); const inputId = useId(); function add() { const text = input.trim(); if (!text) return; setTodos((prev) => [...prev, { id: Date.now(), text, done: false }]); setInput(""); inputRef.current?.focus(); } function toggle(id: number) { setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) ); } function remove(id: number) { setTodos((prev) => prev.filter((t) => t.id !== id)); } const remaining = todos.filter((t) => !t.done).length; return ( <main className="playground"> <header className="playground-header"> <h1>Todos</h1> <p>Add, toggle, and remove todos — useState with arrays.</p> </header> {todos.length > 0 && ( <span className="badge">{remaining} of {todos.length} left</span> )} <fieldset className="control-group"> <legend>Add Todo</legend> <div className="input-row"> <label htmlFor={inputId} className="sr-only">New todo</label> <input id={inputId} ref={inputRef} className="input" value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && add()} placeholder="What needs doing?" /> <button className="toggle-btn" onClick={add} aria-label="Add todo"> Add </button> </div> </fieldset> <section className="demo-area" aria-label="Todo list"> {todos.length === 0 ? ( <p className="empty">Nothing here yet — add something above!</p> ) : ( <ul className="list" role="list"> {todos.map((todo) => ( <li key={todo.id} className={`item${todo.done ? " item--done" : ""}`} > <button className="check" onClick={() => toggle(todo.id)} aria-pressed={todo.done} aria-label={todo.done ? "Mark incomplete" : "Mark complete"} > {todo.done ? "✓" : ""} </button> <span className="text">{todo.text}</span> <button className="remove" onClick={() => remove(todo.id)} aria-label={`Remove "${todo.text}"`} > × </button> </li> ))} </ul> )} </section> </main> ); }
Starting sandbox…
No console output yet.
No original version of /App.tsx to compare against.
Type in the editor above and the preview re-renders live, the same way it would on your own machine.
What you can do with it:
- Edit any file and watch the preview update immediately, no manual refresh.
- Switch between files via tabs, or open the file explorer to browse the whole project.
- Format the active file with Prettier — click the sparkle icon, or just hit
Shift+Alt+F. - Click-and-hold the reset button to restore every file to its original contents and rerun the app from a clean slate.
- Pop the whole thing open in CodeSandbox if you want a full IDE and more room to work.
It's built on CodeSandbox's Sandpack, but only for the bundler and preview iframe — the editor itself is a real CodeMirror 6 instance I've wired up myself, with the feature set I'd actually want from a code editor rather than Sandpack's stock experience:
- Multiple cursors —
Cmd/Ctrl+ click to add a cursor,Cmd/Ctrl+Dto select the next occurrence of whatever's under the caret (keep pressing it to select more), andCmd/Ctrl+Shift+Lto select every occurrence at once. - Rectangular selection — hold
Altand drag to select a column block spanning multiple lines, VS Code/Sublime-style. - Find & replace —
Cmd/Ctrl+Fopens the panel,Cmd/Ctrl+GandShift+Cmd/Ctrl+Gstep through matches,Cmd/Ctrl+Alt+Gjumps to a line. - Inline error diagnostics — a compile error doesn't just print to the console; the offending line gets a red highlight, a squiggly underline, and a gutter dot with a hover tooltip, the same as a real local editor.
- Diff view — every line that differs from the sandbox's original template gets a colored gutter marker (green for added/changed, red for removed); hover or click one for a popup with that hunk's real unified-diff header (
@@ -a,b +c,d @@) and syntax-highlighted, word-level-diffed lines — only the actual word that changed is emphasized, not the whole line. A Diff tab next to Output/Console shows the whole file's diff with surrounding context, a+N -Mstat, buttons to expand the unchanged lines it collapsed, and a one-click copy of the raw diff text. Files with edits get a small dot on their tab, and a toolbar toggle hides the gutter entirely if you'd rather not see it. - Bracket-pair colorization — nested
()/[]/{}pairs are colored by nesting depth, so it's easy to tell at a glance which pair actually matches which. - Code folding — collapse a function, block, or tag from the gutter chevron, or
Cmd/Ctrl+Shift+[/]from the keyboard (Cmd/Ctrl+Alt+[/]folds or unfolds everything at once). On by default; the whole feature — gutter, keymap, and all — can be switched off with the folding toggle in the toolbar,Cmd/Ctrl+Alt+F, or the command palette. - Breadcrumbs and sticky scroll — both off by default, each with its own animated toolbar toggle, keyboard shortcut (
Cmd/Ctrl+Alt+BandCmd/Ctrl+Alt+H), and command palette entry. Try enabling both on the todo app above — it has enough nesting to actually show them off: breadcrumbs show a symbol path (Foo › bar) for wherever your cursor is, click any segment to jump there; sticky scroll pins a nested function/block's own header to the top of the editor as you scroll into its body, so you never lose track of what you're inside. - Indent guides — always on, no toolbar toggle, so nesting structure is visible at a glance.
- Visible whitespace — always on too, but only marks spaces, tabs, and blank lines within whatever you've currently selected, rather than cluttering the whole file.
- Snippets — CSS and HTML ship none of their own, so I added a few common ones (
@media,flex,grid,keyframesfor CSS;html5,link,script,img,afor HTML) — type the prefix and hit Tab.
Support: autocomplete, formatting, and syntax highlighting are all file-type aware — TypeScript/TSX, JavaScript/JSX, CSS/SCSS, HTML, and JSON each get real, relevant suggestions (a .css file suggests margin, padding, and friends, not arbitrary words pulled from elsewhere in the file) instead of one generic completion list. The live syntax colors are also remapped token-by-token to match this post's own static code blocks (see Code Highlighting above) — Sandpack's bundled highlighting is close, but coarser, so without that remapping the live editor and a static snippet would disagree on a few colors for identical code. On narrower screens the split view stacks vertically instead of squeezing two side-by-side panes into no room.
I ended up running my own bundler client underneath Sandpack rather than its default — the only way to get full control over the console output and error states, instead of settling for Sandpack's own cross-origin, unstyleable overlay.
Animations
Subtle motion is provided by Framer Motion. Interactions — button hovers, drawer open/close, theme transitions — use a small set of spring presets defined in a central constants file, so the motion always feels consistent.
The flashlight overlay effect (visible on 404 and error pages) is pure CSS: a radial-gradient on ::before that follows the cursor via two --cursorX / --cursorY custom properties updated on pointermove. No canvas, no WebGL, no JavaScript animation loop.
Analytics
This blog uses Google Analytics 4 for basic traffic insights — which posts get read, where visitors come from, and how people move through the site. It's deliberately lightweight: I'm curious what resonates, not building a profile of anyone.
The wiring is intentionally boring. The measurement ID lives in siteConfig — one source of truth — and the GA script is injected once into <head> via the root layout, so every route is covered without per-page setup. Loading it through Next.js's <Script> with a deferred strategy keeps it off the critical path, so analytics never blocks the first paint.
Beyond page views, a few key interactions are tracked with custom events — for example, each card in the core-stack grid above fires a core_stack_click event via data-analytics-* attributes, so I can see which technologies readers are curious about. No cookies beyond what GA sets itself, no third-party tracking otherwise, and the whole thing is documented on the Privacy Policy page — including how to opt out.
Spam Protection: reCAPTCHA v3
The contact form is the one place on this site that accepts user input, so it needed protection against bots and spam — without the friction of a "click the traffic lights" challenge. Google reCAPTCHA v3 is a perfect fit: it's completely invisible, scoring each interaction from 0.0 (likely a bot) to 1.0 (likely human) based on behavioral signals, with no puzzle to solve.
The integration has two halves:
- Client side — a thin
RecaptchaProviderwraps the form usingreact-google-recaptcha-v3. When the form is submitted, it silently generates a token tied to the action and the public site key (NEXT_PUBLIC_RECAPTCHA_SITE_KEY).
import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
export default function RecaptchaProvider({ children }) {
const siteKey = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY ?? "";
return (
<GoogleReCaptchaProvider reCaptchaKey={siteKey}>
{children}
</GoogleReCaptchaProvider>
);
}- Server side — the
POST /api/contactroute never trusts the client. It forwards the token to Google'ssiteverifyendpoint using the server-only secret key, then rejects anything that fails verification or scores below the0.5threshold Google recommends:
const verifyData = await verifyRes.json();
if (!verifyData.success || verifyData.score < MIN_SCORE) {
return NextResponse.json(
{ error: "reCAPTCHA verification failed." },
{ status: 422 },
);
}Keeping the secret key strictly server-side (in a Route Handler, never shipped to the browser) means the scoring decision can't be tampered with from the client. The result is a form that's frictionless for real people but quietly hostile to bots. Note that reCAPTCHA does load Google's script and analyze page interactions — that's documented in the Privacy Policy and Terms of Use.
Accessibility
Accessibility isn't an afterthought here — it's baked into the component design:
- A skip link is the first focusable element on every page.
- All interactive components use correct ARIA roles and attributes.
- The theme toggle is a
<button>witharia-pressed. - Media queries use
remunits so they respect the user's browser font size preference. - Flashlight and animation effects respect
prefers-reduced-motion. - Color contrast meets WCAG AA across all three themes.
Data: MongoDB
Most of this site is static, but a few features need to persist data between visits — view counts, likes, and contact-form submissions. For that I reach for MongoDB.
A document database is a natural fit here. The shapes I'm storing are small and loosely related — a view counter keyed by post, a like counter keyed by post, a contact message with a timestamp — and a schema-less, JSON-native model means I can evolve those shapes without migrations getting in the way. There's no relational complexity to justify the ceremony of a SQL schema.
All of this lives on the server side, so the database credentials never touch the browser. What gets stored, and for how long, is spelled out on the Privacy Policy page.
The Like Button
Every post, snippet, and series entry has a small animated heart living in the sidebar — or floating nearby on pages with no sidebar to sit in. It's not just a static icon: click it and the heart fills in, bounces, bursts into a handful of emoji confetti, and a little "Thanks for the love! ❤️" bubble floats upward and fades out. Click an already-liked heart and it still bounces for you, just with a gentler "You already liked this! 💛" instead.
Go ahead and try it — this one's a demo, wired up to nothing but local component state, so it won't touch this post's real count:
The interactive personality is built entirely from scratch:
- The heart leans away from your cursor as it gets close, and its eyes track the pointer — pure geometry (angle to cursor, clamped displacement) feeding a
@react-spring/webrotation and eye-translation. No mouse-tracking library, justatan2and a bit of trigonometry doing the heavy lifting. - Confetti on success — a burst of ❤️💖✨💕🎉 characters fly outward with per-particle randomized spring physics (distance, rotation, mass all vary), then fade and get swept up by a garbage-collection interval. No image sprite sheet required — the "sprites" are just emoji.
- The count is real, stored in MongoDB the same way page views are: one document per post, keyed by its slug.
Since there's no login system on this blog, "one like per visitor" is enforced the same way the view counter avoids double-counting a refresh: a hashed IP address, backed by a MongoDB unique index that makes the first like for a given (post, visitor) pair succeed and every later one fail outright. No separate "have they already liked this?" check is needed — the database itself enforces it by rejecting the duplicate:
await db.collection("page_like_seen").insertOne({ slug, ipHash, at: new Date() });
// A duplicate (slug, ipHash) pair throws a Mongo error (code 11000) instead of
// inserting — that failure *is* the "already liked" signal.Deployment
The site is deployed on Vercel, which pairs naturally with Next.js — zero-config builds, automatic preview deployments per branch, and Edge Network CDN. The domain points to Vercel via a simple CNAME.
Source lives on GitHub. Every push to main triggers a production deployment automatically.
If you have any questions or doubts regarding the core stack, feel free to drop me a message via the contact page or use the social links in the footer.