Building a blog that bends to you

If you're a developer considering a blog, you've likely felt the overwhelm of choice. There's no shortage of frameworks, CMSes, and static site generators, but the real question is whether your content can be anything more than paragraphs and headings.

For me, the central requirement was the ability to embed fully custom content inside a post — interactive elements, one-off widgets, demos that readers can play with. Markdown or a rich-text editor typically locks you into a fixed set of rendered HTML tags. You can get bold and italic, maybe a table or two, but you're not going to drop in a Spring physics simulator.

That need drove every other choice on this blog. What follows is the roadmap I landed on, along with answers to the questions readers ask most often.

The core stack

The site itself is a Next.js application. All blog posts are statically generated at build time; for anything needing a persistent backend, like storing per-post popularity or hit counts, I use Next.js API Routes backed by https://www.mongodb.com/. Deployment happens on Vercel, which I picked both for their Next.js connection and because the platform itself has been genuinely excellent.

Styling is done with styled-components, written entirely from scratch — no UI frameworks or Bootstrap-style libraries. I do pull in Reach UI for things like modals. Animations lean mostly on React Spring, with some recent experiments in Framer Motion.

But the single most important piece of the puzzle is MDX.

Why MDX changes the game

MDX is Markdown extended to allow importing and using custom React components. If you've ever seen a README.md, you know Markdown: asterisks become <strong>, dashes become list items, and a compile step turns everything into HTML the browser can read.

The limitation is that compile step only knows a handful of HTML elements. MDX removes the ceiling — instead of being limited to italic and bold, I can define my own rich primitives for things like spicy text or custom callouts. And it's not just styled text: I can build entirely bespoke, one-off widgets and drop them directly into a post.

On this blog, one article about spring physics includes a draggable, interactive SpringMechanism component. The same component accepts props to demonstrate effects like mass and tension side-by-side. Readers aren't just reading about physics — they're manipulating it. That shift from passive reading to active experimentation is impossibly more compelling than a video or text description.

This raises a natural question: why not build each post as a route in a standard React app instead of using MDX at all?

I tried that when I first started, but MDX wins for two reasons:

  1. Authoring is dramatically nicer. No need to wrap everything in <p> tags when asterisks do the work.
  2. Markdown is data. Because each post is a data file rather than a component, I can extract metadata to filter lists — recent posts, older pieces — conditionally for different pages. A React-component-per-post approach doesn't offer that easily.

For a solo developer, MDX lands as the sweet spot between full-code applications and CMS-driven data. There's a slight learning curve, so I point newcomers to the official docs, Rodrigo Pombo's “The X in MDX”, and Laurie Barth's video on MDX v2 syntax.

Integrating MDX with Next.js

The MDX ecosystem with Next has multiple integration points, currently four major options: @next/mdx, Hashicorp's next-mdx-enhanced and next-mdx-remote, and Kent C Dodd's mdx-bundler.

I use next-mdx-remote on both this blog and my course platform. It's worked well, but the notable limitation is that you can't import one-off components directly inside MDX files, so every bespoke widget must live in one monolithic MDX bundle. Avoiding performance problems requires significant use of lazy-loading, introducing meaningful Developer Experience friction.

Storing and accessing metadata

Posts need more than just body content; they need titles, abstracts, and publication dates. I handle this with frontmatter, a Markdown addon for key-value pairs at the top of a document. The specific mechanism for accessing these values is determined by your MDX tool; with next-mdx-remote, I reference a React layout component, which receives the frontMatter data and the article content as its children.

Index pages and dynamic lists

The homepage features two independent lists: a chronological list of the 20 newest posts and a ranking of the 10 most-viewed all-time posts. During the build with Next's getStaticProps, I read the filesystem to:

  1. Collect all .mdx files from the pages directory with fs.readdirSync.
  2. Load access their frontmatter using the gray-matter package.
  3. Filter out any post where isPublished isn't true.
  4. Sort those remaining by publishedOn and truncate to the requested limit.
  5. Return the top results.

This low-level approach feels raw after working with Gatsby's magical GraphQL querying, but I've grown to appreciate its simplicity and the control it restores — like when reusing similar queries against fetched popularity data for sorting an index of most-viewed posts.

Handling the lightweight backend

The blog is fundamentally a static site, but with data interactions. A hit counter reminiscent of the web's early days increments in a MongoDB database for every page load, triggered from a Next.js API Route of the same type used for the heart-shaped "likes" button on each post.

The like limit is unusual: each user can click 16 times.

The original system relied on localStorage, but a friendly community member revealed the flaw by inflating a counter with nearly 40,000 fake clicks. The workaround now uses the user's IP address (hashed for privacy) from Vercel's request headers to check each request against a per-post map of user-to-likes-count.

Surprisingly, Next.js Route Handlers hold up to far bigger jobs. This blog could run on a bare-bones hosting setup, but I'm using the same stack to run the course platform, a fully dynamic app with authentication, roles, transactional email, and more. That's a demanding but manageable workload for this architecture.

Build-time helpers

A migration from Gatsby to Next.js mostly came down to context-switching — my course platform was already in Next, and I wanted to simplify the maintenance burden. Gatsby's rich plugin ecosystem delivered an RSS feed and sitemap automatically; Next’s less opinionated philosophy required me to write these myself.

My approach was to add a build-helpers folder containing specific Node scripts, set to run just before every site build. A valuable NPM trick: using the pre prefix on a script means it automatically runs before npm run build. So adding a prebuild script handles these tasks for me every time.

Take the RSS feed, for example. The process also covers the similar steps for a sitemap:

  1. Use the rss npm package to handle XML formatting.
  2. Locate all the .mdx files in pages with fs.readdirSync.
  3. Pull the title and abstract field from each file's front matter using gray-matter.
  4. Skip any post flagged with isPublished: false.
  5. Insert each post as an item in the RSS feed.
  6. Write the generated .xml file into the public directory.

Next.js copies files in public directly to a static directory, so they serve publicly. I then add the generated output to .gitignore since it's rebuilt every time.

This entire approach is a template for building quickly with maximum creative control, at the cost of slightly more assembly — a worthwhile trade for anyone who needs their content to be more than text.

Building the blog’s visual identity

The avatar you see around the site isn’t something I designed myself. I commissioned an artist to create it, which gave us several facial expressions and two lighting modes that switch with the site’s theme. I believe the total cost was around US$500.

Everything else on the front end, I designed and built myself. That wasn’t easy for me at first, but I’ve picked up a few strategies that made it manageable:

  • Learn from people who do it professionally. When collaborating with designers, I ask specific questions about layout choices, color usage, and the reasoning behind decisions. This builds up the design intuition you need to make good calls later.
  • Combine existing work instead of starting from scratch. For years, I’d find several solid references, pull the layout from one, the color system from another, and the typography from a third. Combining designs you respect takes less effort than inventing a new one from nothing. As it turns out, even professional designers lean heavily on this practice.
  • Step away after the first layout. Spending focused hours on a design blurs your perception of its quality. Leaving it alone for a day or two gives you the distance needed to judge whether it actually works.

My aim isn’t to reach world-class designer status. That’s a full career in its own right. What I’ve found is that a modest investment and a couple of deliberate shortcuts get you to a level where you feel good about what you ship.

Handling code samples

Code is core to a developer blog, so I use different mechanisms depending on the context. Standard Markdown fenced blocks map to a StaticCodeSnippet component when I want a static, highlighted sample:

.wrapper {
  width: 800px;
  padding: 32px;
}

When readers need to edit code directly, I use the <Playground> component. It lets readers toggle between HTML, CSS, and a live result:

<div class="wrapper">  <h2>Hello World</h2></div>
.wrapper {  display: flex;  justify-content: center;  align-items: center;  background: white;  width: 250px;  max-width: 80vw;  height: 250px;  margin: 0 auto;  border-radius: 4px;}body {  display: grid;  place-content: center;  height: 100vh;  background: silver;}

I forked agneym’s Playground for this component, keeping the core rendering logic but making cosmetic and usability changes. In MDX, the setup looks like this:

<Playground
  html={`
<div class="wrapper">
  <h2>Hello World</h2>
</div>
  `}
  css={`
.wrapper {
  display: flex;
  justify-content: center;
}
\n\
body {
  height: 100vh;
  background: silver;
}
  `}
/>

The authoring side isn’t perfect — no syntax highlighting in the source, and you have to be careful with indentation. I frequently end up writing code in the live editor itself and copying it into the MDX file afterward.

There’s also an MDX gotcha: blank lines inside React elements can cause hard-to-trace errors. To add a clear line between CSS rules, adding \n to the source creates an extra blank line because you still have the physical linebreak. Escaping with \n\ cleans this up by consuming the newline normalization.

A date problem and its fix

It’s embarrassing but true: I’ve occasionally published “new” posts that show a stale “last updated” date from months or years earlier. The issue comes from how I create posts. Each document has publishedOn and optionally updatedOn fields in frontmatter. When starting a new draft, I usually copy frontmatter from a recent post and sometimes forget to reset either date.

The logical fix is to derive the updated timestamp from the file system — the OS can track when a file was last modified. That won’t work here, though, because this blog builds on Vercel’s servers, where every deploy is a fresh clone, so every file is new from the filesystem’s perspective. A reader later showed me a solution: use lint-staged to modify the .mdx file at commit time, capturing the correct update moment before the build starts. Implementing that has been reliable ever since.

Component organization

My React app-level components live in src/components and easily top 150 items, including things like Logo, RainbowButton, and Boop. Each one has its own folder:

components/
├─ Boop/
│  ├─ index.js
│  ├─ Boop.js
│  ├─ useBoop.js
├─ Logo/
│  ├─ index.js
│  ├─ Logo.js
│  ├─ Logo.helpers.js
│  ├─ logo.svg

Its keeps everything tidy, and it lets a component span multiple files. There’s also an src/post-helpers directory across the site where posts that need one-off elements—like the SpringMechanism component earlier—get their temporary utility code. Since Next and its MDX plugin mandate that posts live under pages, colocating helpers inside post directories wasn’t feasible.

Answers to common questions

How do you manage testing?

There isn’t much of a testing strategy, since this is a static site with no critical user flows tying everything together. That said, my course platform uses Playwright, and the experience has been a positive one.

Where do article ideas come from?

I went into more detail on this topic recently in a newsletter archive issue covering my writing process.

How are embedded tweets handled?

Using the standard Twitter SDK comes at a performance cost, so I dropped it in favor of a small custom component. FakeTweet renders the same aesthetic as a standard tweet without external script overhead:

It covers the tweet’s key elements and keeps pages fast.