A file-based blog in Next.js, without a CMS
Markdown files, frontmatter, and about eighty lines of TypeScript - why I stopped reaching for a headless CMS on small sites.
Every time I've added a blog to a small site, the default suggestion is a headless CMS. For a personal site that publishes a few times a year, that's an external service, an API key, and a build dependency in exchange for a text editor I'd use maybe ten times.
Files work better at this size. Here's the whole setup.
Posts are just files
Each post is an .mdx file with YAML frontmatter:
---
title: A file-based blog in Next.js
summary: Markdown files and about eighty lines of TypeScript.
date: 2026-08-10
category: tech
tags: ['Next.js', 'MDX']
---
The body starts here, and it's just Markdown.gray-matter splits that into data and content. Wrap it in a loader that
reads the directory, and you have a blog:
function allPosts(): Post[] {
return fs
.readdirSync(BLOG_DIR)
.filter((file) => /\.mdx?$/.test(file))
.map(readPost)
.sort((a, b) => b.date.localeCompare(a.date));
}Two details worth getting right
Validate frontmatter loudly. A post missing a date shouldn't silently sort
to the bottom or crash at render time - it should fail the build with the
filename in the message:
if (!data.date) {
throw new Error(`content/blog/${fileName} is missing a "date".`);
}You write posts infrequently, which means you'll forget the format. A clear build error is the cheapest possible reminder.
Normalise the date immediately. YAML parses an unquoted 2026-08-10 into a
Date object, but a quoted one stays a string. Pick one representation at the
boundary and stop thinking about it:
date: new Date(data.date).toISOString().slice(0, 10),Sorting then works on plain strings, and YYYY-MM-DD sorts lexicographically -
so localeCompare is all you need.
Drafts
A draft: true flag that hides posts in production but shows them in dev gives
you a preview workflow for free:
const visible = allPosts().filter(
(post) => !post.draft || process.env.NODE_ENV === 'development',
);One catch: generateStaticParams should still return draft slugs, otherwise
the route doesn't exist at all in a production build and you can't share a
preview link.
Rendering
next-mdx-remote/rsc renders MDX in a server component, so no client
JavaScript ships for the post body. The rehype plugin chain handles the rest -
rehype-slug for heading IDs, rehype-pretty-code for syntax highlighting,
rehype-autolink-headings for anchor links.
One thing to know about rehype-pretty-code: give it both a light and dark
theme and it emits both as CSS variables on the same markup. You choose in CSS
rather than at build time, which means highlighting follows a runtime theme
toggle correctly.
When this stops working
This setup breaks down when someone who doesn't use git needs to publish, or when you want scheduled posts. Neither applies to a personal site. Until one does, files are less machinery for the same result - and the posts live in the same repo as the code, versioned alongside it.