Next.js Static Architecture TypeScript Headless CMS

Markdown-Driven Content: How to Build a Blog Without a CMS

Static Signal
Markdown Driven Blog

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.