The blog you're reading has no database and no hosted CMS. Every post is a folder in the Git repository with an index.md and its images. Pushing a commit builds the site and publishes the post. Here's how to build the same thing with the Next.js App Router.
Why Markdown in Git
- Free and fast. No CMS subscription, no database queries; every page is static HTML.
- Versioned. Every edit is a commit you can review and roll back.
- Portable. Markdown outlives any platform. Moving to another framework means changing the renderer, not migrating content.
- Editable anywhere. GitHub's web editor, VS Code, or a Git-based CMS like Decap or Sveltia if someone prefers a form.
The trade-off: non-technical editors need either a little Git or a Git-based CMS in front of it.
The folder structure
One folder per post keeps images next to the text that uses them — and the relative links also preview correctly on GitHub:
content/blog/
my-first-post/
index.md
cover.jpg
diagram.pngA post starts with YAML front matter:
---
title: My first post
date: 2026-10-08
excerpt: One or two sentences for the listing and the meta description.
cover: ./cover.jpg
tags: [Next.js, Markdown]
---
The post starts here.Dependencies
npm install gray-matter remark remark-gfm remark-html highlight.jsgray-mattersplits front matter from the body.remark+remark-gfmparse Markdown with GitHub's extensions (tables, task lists).remark-htmlturns it into HTML.highlight.jshighlights code blocks at build time, so readers download no highlighter.
Loading the posts
import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
import { remark } from 'remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'
const DIR = path.join(process.cwd(), 'content/blog')
export async function getAllPosts() {
const slugs = fs.readdirSync(DIR).filter((name) =>
fs.existsSync(path.join(DIR, name, 'index.md')),
)
const posts = await Promise.all(slugs.map(getPost))
return posts.sort((a, b) => b.date.localeCompare(a.date))
}
export async function getPost(slug: string) {
const file = fs.readFileSync(path.join(DIR, slug, 'index.md'), 'utf8')
const { data, content } = matter(file)
const html = String(
await remark().use(remarkGfm).use(remarkHtml, { sanitize: false }).process(content),
)
return {
slug,
title: String(data.title),
date: new Date(data.date).toISOString().slice(0, 10),
excerpt: String(data.excerpt ?? ''),
html,
}
}sanitize: false lets raw HTML in posts through — fine when you are the only author. If other people can submit posts, keep sanitizing.
Because this only runs in Server Components and generateStaticParams, fs is fine: it runs at build time, never in the browser.
The pages
The index lists posts; the detail route is static for every slug:
import { getAllPosts, getPost } from '@/lib/blog'
export const dynamicParams = false
export async function generateStaticParams() {
return (await getAllPosts()).map(({ slug }) => ({ slug }))
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
)
}Syntax highlighting at build time
A small remark plugin replaces each code node with highlighted HTML:
import hljs from 'highlight.js'
import { visit } from 'unist-util-visit'
export function highlightCode() {
return (tree: any) => {
visit(tree, 'code', (node: any, index, parent: any) => {
const lang = node.lang && hljs.getLanguage(node.lang) ? node.lang : null
const html = lang
? hljs.highlight(node.value, { language: lang }).value
: hljs.highlightAuto(node.value).value
parent.children[index!] = {
type: 'html',
value: `<pre><code class="hljs language-${lang ?? 'text'}">${html}</code></pre>`,
}
})
}
}Add it before remarkHtml with .use(highlightCode), and import a highlight.js theme such as highlight.js/styles/github-dark.css.
Images and video
Next.js serves static files from public/, so post assets need to end up there. A tiny script that runs before dev and build copies everything that isn't Markdown:
import fs from 'node:fs'
import path from 'node:path'
const SRC = 'content/blog'
const OUT = 'public/blog-media'
fs.rmSync(OUT, { recursive: true, force: true })
for (const slug of fs.readdirSync(SRC)) {
const dir = path.join(SRC, slug)
if (!fs.statSync(dir).isDirectory()) continue
for (const file of fs.readdirSync(dir)) {
if (file.endsWith('.md')) continue
fs.mkdirSync(path.join(OUT, slug), { recursive: true })
fs.copyFileSync(path.join(dir, file), path.join(OUT, slug, file))
}
}{
"scripts": {
"predev": "node scripts/copy-blog-media.mjs",
"prebuild": "node scripts/copy-blog-media.mjs"
}
}Then, in another remark plugin, rewrite relative image URLs (./cover.jpg) to /blog-media/<slug>/cover.jpg. While you're there, turn images that point at .mp4 or .webm files into <video controls> elements, and YouTube links into embeds — authors keep writing plain Markdown image syntax.
Add public/blog-media to .gitignore; it's generated.
Publishing
Connect the repository to a static host — Cloudflare Pages, Netlify or Vercel — with npm run build as the build command. Now writing a post is:
- Create a folder and an
index.md. - Commit and push.
- The host builds and deploys in a couple of minutes.
SEO extras worth adding
generateMetadatawith the post's title, excerpt, canonical URL and cover as the Open Graph image.- A
sitemap.tsthat lists every post. BlogPostingJSON-LD.- Heading IDs, so sections can be linked to.
All of these are covered in technical SEO for the Next.js App Router.
When to outgrow it
If several non-technical people publish daily, need scheduled posts or editorial workflows, a headless CMS starts paying for itself. For a personal site, a studio or a product blog with a few posts a month, Markdown in Git is hard to beat.

