How I Built This Blog
A short look at how I added a fast, static-first blog to my personal site without turning it into a heavy CMS.
2026-07-03engineering4 min read
- blog
- personal-site
- react
- static-sites
- tanstack-start
- typescript
- mdx
I wanted this blog to feel like a natural extension of my personal site, not a separate product bolted onto it. The portfolio was already fast, sharp, and low-maintenance, so the blog had to follow the same philosophy: simple architecture, strong defaults, and as little runtime complexity as possible.
The short version: posts are MDX files in the repository, a small script turns them into typed data at build time, and everything ships as static pages. No database, no admin panel, no CMS. I almost built an admin panel one again. I didn't.
The rules I set for myself
- Writing lives next to the code, in the same git history. A post gets reviewed like any other diff.
- The browser gets static assets. A server should not be involved in reading a blog post.
- Anything that can fail should fail at build time, loudly, before it ever deploys.
- If a feature needs maintenance on a quiet week, it doesn't go in.
The stack
| Layer | Choice | Why |
|---|---|---|
| Framework | TanStack Start + React | File-based routes, static-first rendering |
| Content | MDX | Markdown that can grow components when needed |
| Styling | Tailwind CSS | One design system shared with the portfolio |
| Validation | Zod | Frontmatter is checked, not trusted |
| Highlighting | Shiki via rehype-pretty-code | Real syntax highlighting, done at build time |
| Hosting | Vercel | Push to main, get a deploy |
Where posts live
Every post is a single .mdx file in content/blog/. The frontmatter at the top of this very file looks like this:
title: How I Built This Blog
publishedAt: 2026-07-03
category: engineering
tags:
- blog
- mdx
featured: true
draft: falseThat frontmatter is not just a convention. Before the site builds, a script parses every post and validates it against a schema:
export const postFrontmatterSchema = z
.object({
title: z.string().trim().min(1),
description: z.string().trim().min(1),
publishedAt: z.iso.date(),
updatedAt: z.iso.date().optional(),
category: normalizedLabel,
tags: z.array(normalizedTag).min(1),
featured: z.boolean().optional().default(false),
draft: z.boolean().optional().default(false),
})
.strict()A typo in a date or a stray field kills the build immediately. I would much rather argue with a build error than discover a broken post in production.
What happens at build time
- The script reads every MDX file, validates the frontmatter, and computes the reading time.
- It writes a typed manifest into
src/generated/— plain TypeScript the rest of the app imports like any other module. - Drafts are included in development and filtered out of production builds, so I can preview half-written posts locally without ever shipping them.
- MDX compiles to React components, with GFM tables, heading anchors, and Shiki syntax highlighting all baked in ahead of time.
- The pages render as static HTML, along with the RSS feed, the sitemap, and an Open Graph image.
The pattern I keep coming back to: move work from request time to build time. Every one of these steps runs once per deploy instead of once per visitor.
The small details
Most of the polish is in things you only notice when they're missing:
- Code blocks get a copy button, and the copy status is announced to screen readers.
- The table of contents is generated from the actual headings, so it can't drift out of date.
- Pressing
/on the blog index focuses the search box. - Search and filters live in the URL, so a filtered view survives a refresh and can be shared.
- External links get
rel="noopener noreferrer"automatically — I never have to remember it. - Accessibility checks and Lighthouse budgets run in CI, so regressions get caught before I do.
What I deliberately skipped
- A database. Git already stores text, tracks history, and syncs everywhere.
- An admin UI. My editor is the admin UI. Authentication for one person is a lot of code for zero users.
- Comments. If something here is wrong, my email is in the footer.
In the end, the blog is deliberately boring in the places infrastructure should be boring. It is fast because it is mostly static. It is flexible because it is part of the app. It is maintainable because the content and the code are close together. And it still feels like mine, which was the real requirement all along.
That is the kind of software I like building: not the most elaborate version, just the version that fits the shape of the problem.