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.tsThat file handles reading posts, parsing frontmatter, validating metadata, and returning clean data for the UI.
Where posts live
All posts live in:
content/blog/postsBoth .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-worksFrontmatter 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:
- Verifies frontmatter exists and is closed.
- Reads
key: valuepairs. - Splits
tagsandkeywordsinto arrays. - 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:
titlesectionpublishedAtreadTimesummary
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.