← Writing
How-to

How I set up this blog: Next.js, MDX, and a publish flow I actually use

2026-03-10 · 4 min read

I've started and abandoned more personal blogs than I can count.

The pattern was always the same: pick a platform, spend three weekends customising it, write one post, lose interest. The friction wasn't the writing — it was everything around the writing. Logging into a CMS, fighting a rich text editor, wondering if my formatting would survive a deploy.

This time I built it differently. The constraint I set myself: writing a post should feel as low-friction as opening a file and typing.

Here's what I ended up with and why.

The stack

  • Next.js 14 with the App Router
  • MDX files stored directly in the Git repository under /content/posts/
  • next-mdx-remote for parsing and rendering
  • gray-matter for frontmatter
  • Vercel for hosting, connected to GitHub for automatic deploys

No database. No CMS login. No third-party service that can go down or change its pricing.

Why MDX in Git instead of a CMS

I considered every option: Contentful, Sanity, Notion as a CMS, even a plain Markdown setup. I kept coming back to the same question: what happens when I just want to fix a typo at 11pm?

With a CMS, the answer involves logging in somewhere, finding the post, making the change, saving, waiting for a webhook. With MDX in Git, the answer is: open the file, fix it, commit, push. Vercel deploys in under a minute.

There's also something I didn't expect: writing in a code editor is actually great for technical posts. Syntax highlighting in the editor, the same keyboard shortcuts I use all day, no context switching. It turns out the tool I spend eight hours a day in is also a pretty good writing tool.

The folder structure

content/
  posts/
    how-i-set-up-this-blog.mdx
    what-code-review-taught-me.mdx
src/
  app/
    blog/
      page.tsx          ← listing page
      [slug]/
        page.tsx        ← single post
  lib/
    posts.ts            ← file reader + parser

Clean separation: content lives in /content, rendering logic lives in /src. The posts know nothing about Next.js. The components know nothing about the filesystem.

lib/posts.ts — the core

The whole pipeline runs through one file. It does three things:

  1. Reads all .mdx files from /content/posts/
  2. Parses frontmatter with gray-matter
  3. Returns posts sorted by date
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import readingTime from 'reading-time'
 
const postsDirectory = path.join(process.cwd(), 'content/posts')
 
export function getAllPosts() {
  const filenames = fs.readdirSync(postsDirectory)
 
  return filenames
    .filter(f => f.endsWith('.mdx'))
    .map(filename => {
      const slug = filename.replace('.mdx', '')
      const fullPath = path.join(postsDirectory, filename)
      const fileContents = fs.readFileSync(fullPath, 'utf8')
      const { data } = matter(fileContents)
 
      return {
        slug,
        ...data,
        readingTime: readingTime(fileContents).text
      }
    })
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}

No magic. No abstraction layer. Just fs.readFileSync and gray-matter.

The publish flow

Every post goes through a GitHub PR. I write the MDX file locally, push to a branch, open a PR. Vercel automatically builds a preview URL for that branch. I review the rendered post in the preview, merge if it looks good, and the post goes live.

The key constraint: only a merged PR publishes a post. No accidental drafts going live. No "oops I forgot to unpublish that". The Git history is also the content history — I can see every edit, every draft, every change.

What I'd do differently

If I were starting over I'd set up syntax highlighting earlier. I bolted on rehype-pretty-code after the fact and had to adjust the MDX component mapping. Should have been in from day one.

I'd also write a small script to scaffold new post files — just a create-post.ts that generates the frontmatter template and opens the file. The blank-file problem is real.

Everything else I'd keep exactly the same.