How I set up this blog: Next.js, MDX, and a publish flow I actually use
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:
- Reads all
.mdxfiles from/content/posts/ - Parses frontmatter with
gray-matter - 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.