Skip to main content

Harshal V. LADHE

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:

Next.jsv16

The React framework for the web. Handles routing, rendering, bundling, and image optimisation out of the box.

Reactv19

The UI library everything is built on. Server Components, hooks, and a component model that scales from a single button to the whole page.

TypeScriptv5

Static types across every file. Catches mistakes at compile time and makes refactoring fearless.

MDXv3

Markdown with superpowers. Every blog post is an MDX file — full React component support right inside prose.

Linariav8

Zero-runtime CSS-in-JS. Compiles styled components to plain CSS modules at build time — no style injection, no hydration cost.

SCSSv1

Sass modules for global typography, animation keyframes, and design abstractions that live outside the component tree.

Framer Motionv12

Physics-based animations for layout transitions, hover effects, and the mobile navigation drawer.

React Springv10

Spring-physics animation library. Powers the booping icons and hover micro-interactions with natural, interruptible motion instead of fixed-duration easing.

Shikiv4

Syntax highlighter powering every code block. Uses real TextMate grammars and VS Code themes to colour code at build time — no client-side highlighting.

Sandpackv2

Live, in-browser code playgrounds from CodeSandbox. Lets readers run and edit interactive React examples right inside a post.

Vercel

Deployment and hosting. Zero-config builds, automatic preview URLs per branch, and a global CDN.

MongoDBv8

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 themeslight, dark, and soft-dark — each a complete set of semantic color variables.
  • Anti-FOUC — a tiny inline <script> in <head> reads the saved theme from localStorage and applies data-theme to <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>
  );
}

Ln , Col TypeScript React3.0 KBUTF-8

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 cursorsCmd/Ctrl + click to add a cursor, Cmd/Ctrl + D to select the next occurrence of whatever's under the caret (keep pressing it to select more), and Cmd/Ctrl + Shift + L to select every occurrence at once.
  • Rectangular selection — hold Alt and drag to select a column block spanning multiple lines, VS Code/Sublime-style.
  • Find & replaceCmd/Ctrl + F opens the panel, Cmd/Ctrl + G and Shift + Cmd/Ctrl + G step through matches, Cmd/Ctrl + Alt + G jumps 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 -M stat, 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 + B and Cmd/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, keyframes for CSS; html5, link, script, img, a for 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 RecaptchaProvider wraps the form using react-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/contact route never trusts the client. It forwards the token to Google's siteverify endpoint using the server-only secret key, then rejects anything that fails verification or scores below the 0.5 threshold 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> with aria-pressed.
  • Media queries use rem units 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:

12

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/web rotation and eye-translation. No mouse-tracking library, just atan2 and 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.