Nathan Gwyn
Back to blog
Programming2026-03-217 min read

How My Blog System Works (And Why I Built It This Way)

A breakdown of how my file-based MDX blog loader works, why the validation is strict, and how new posts become routes automatically.

nextjsmdxcontent-systemarchitecture

I wanted a blog workflow that felt simple: write markdown, commit, ship.

No admin panel, no external CMS, no mystery state.

So I built a file-based system around one core file:

// src/lib/blog.ts

That file handles reading posts, parsing frontmatter, validating metadata, and returning clean data for the UI.

Where posts live

All posts live in:

content/blog/posts

Both .md and .mdx are supported:

const blogPostExtensions = [".mdx", ".md"] as const;

Slug comes from filename:

const slug = file.name.replace(/\.(md|mdx)$/, "");

So how-the-blog-system-works.mdx becomes:

/blog/how-the-blog-system-works

Frontmatter parsing

Each post starts with frontmatter:

---
title: Example Post
section: Programming
publishedAt: 2026-03-21
readTime: 4 min read
summary: What this post is about.
tags: nextjs, mdx
keywords: blog, file-based content
---

The parser:

  1. Verifies frontmatter exists and is closed.
  2. Reads key: value pairs.
  3. Splits tags and keywords into arrays.
  4. Returns frontmatter + body.

The strict checks look like this:

if (!fileContents.startsWith("---\n")) {
  throw new Error("Blog post is missing frontmatter.");
}
 
const closingMarkerIndex = fileContents.indexOf("\n---\n", 4);
if (closingMarkerIndex === -1) {
  throw new Error("Blog post frontmatter is not closed.");
}

Why validation is strict

I validate required fields before anything renders:

  • title
  • section
  • publishedAt
  • readTime
  • summary

If one is missing, it throws immediately.

That’s intentional. I’d rather fail fast than silently publish broken content.

Section rules

Sections are intentionally constrained:

export const blogSections = [
  "Programming",
  "Personal",
  "Projects",
  "Field Notes",
] as const;

If I typo a section name, the system catches it.

This keeps filtering/styling/search consistent.

Sorting and summaries

Posts are sorted newest first:

return posts.sort(
  (left, right) => toEpoch(right.publishedAt) - toEpoch(left.publishedAt),
);

For listing/search UI, I map full posts into summary objects that include:

  • visual accent classes per section
  • search document text (title + summary + tags + body)

How routes get generated

The index page (/blog) pulls everything from getBlogPosts() and renders summaries.

The dynamic route (/blog/[slug]) uses:

export function generateStaticParams() {
  return getBlogPosts().then((posts) =>
    posts.map((post) => ({ slug: post.slug })),
  );
}

That means new post files automatically become routes at build time.

Inside the post page:

const post = await getBlogPost(slug);
if (!post) notFound();

Then MDX body is evaluated and rendered with custom components.

Why I like this setup

It’s boring in the best way:

  • write content in files
  • commit to git
  • deploy
  • done

No CMS sync bugs. No hidden state. No duplicated sources of truth.

If you like writing and shipping quickly, file-based content still wins.