Markdown-Driven Content: How to Build a Blog Without a CMS
Every time I’ve inherited a WordPress site to migrate or update, the same problems show up: outdated plugins, a theme nobody remembers installing, a database that hasn’t been backed up in months, and a page builder that turned simple content into 400 lines of shortcode soup.
The CMS was supposed to make things easier. Usually it didn’t.
Markdown-driven content is the alternative. Your content lives in .md files, version-controlled in Git, readable by any text editor and any AI agent. No admin panel to log into. No database to back up. No attack surface for bots to probe.
Here’s how to build a blog with it in Next.js.
The Core Idea
Instead of storing posts in a database and fetching them at runtime, you store them as .md files in your repo and read them at build time. Next.js generates a static HTML page for each one. The result is a blazing-fast blog with zero server-side dependencies.
Your content directory ends up looking like this:
content/
blog/
2026-01-15-my-first-post.md
2026-02-03-another-post.md
2026-03-01-latest-post.md
Each file starts with YAML front-matter followed by the post body in standard Markdown:
title: "Why Static Sites Are Making a Comeback"
date: "2026-03-01"
description: "The case for building without a CMS in 2026."
tags: ["jamstack", "next.js", "static"]
Your post content starts here. Standard Markdown works exactly as expected.
Setting Up the Content Reader
Install the two packages you need:
npm install gray-matter remark remark-html
gray-matter — parses YAML front-matter from .md files
remark + remark-html — converts Markdown body to HTML
Create lib/posts.ts:
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
const postsDirectory = path.join(process.cwd(), 'content/blog');
export function getAllPosts() {
const fileNames = fs.readdirSync(postsDirectory);
return fileNames
.filter((name) => name.endsWith('.md'))
.map((fileName) => {
const slug = fileName.replace(/\.md$/, '');
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data } = matter(fileContents);
return {
slug,
...(data as { title: string; date: string; description: string; tags: string[] }),
};
})
.sort((a, b) => (a.date < b.date ? 1 : -1));
}
export async function getPostBySlug(slug: string) {
const fullPath = path.join(postsDirectory, `${slug}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
const processedContent = await remark().use(html).process(content);
const contentHtml = processedContent.toString();
return {
slug,
contentHtml,
...(data as { title: string; date: string; description: string; tags: string[] }),
};
}
The Blog Index Page
app/blog/page.tsx:
import { getAllPosts } from '@/lib/posts';
import Link from 'next/link';
export default function BlogIndex() {
const posts = getAllPosts();
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
<time>{post.date}</time>
<p>{post.description}</p>
</Link>
</li>
))}
</ul>
</main>
);
}
The Individual Post Page
app/blog/[slug]/page.tsx:
import { getAllPosts, getPostBySlug } from '@/lib/posts';
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
);
}
generateStaticParams tells Next.js which slugs to pre-render at build time. With output: ‘export’ set, every post becomes a static HTML file.
Adding a New Post
This is the whole workflow:
# 1. Create the file
touch content/blog/2026-03-10-new-post.md
# 2. Write the front-matter and content
# (your editor, Claude, whatever)
# 3. Commit and push
git add content/blog/
git commit -m "blog: new post title here"
git push origin main
# 4. Render auto-deploys, post is live in ~60 seconds
No admin panel. No database migration. No plugin conflict. Just a file.
Why This Works So Well with AI Agents
The file-based approach is particularly well-suited to AI-assisted content management. An AI agent can create a new post by writing a .md file with correct front-matter and valid Markdown — no API credentials, no CMS schema to understand, no session to authenticate.
The constraint becomes a feature: if you can describe what a valid post file looks like, any agent can create one. We use this pattern extensively in our automated client build pipeline, which is covered in detail in a separate post.
The Tradeoff to Acknowledge
Markdown-driven content isn’t the right choice for every project. If a non-technical client needs to update their own blog, handing them a GitHub repo and a Markdown editor is probably not the move. For those cases, a headless CMS with a writing interface (Sanity, Contentlayer, Tina CMS) sits one layer above this pattern and still lets you keep the static output.
But for developer blogs, agency sites, and any project where you or a technical collaborator controls the content? Files in a repo beat a database almost every time.