Skip to content
jarrettyew.com
← All writing

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.

Tech3 min readNext.jsMDX

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.