Der Blog, den Sie gerade lesen, hat keine Datenbank und kein gehostetes CMS. Jeder Beitrag ist ein Ordner im Git-Repository mit einer index.md und den zugehörigen Bildern. Ein Push eines Commits baut die Website und veröffentlicht den Beitrag. So bauen Sie dasselbe mit dem Next.js App Router.
Warum Markdown in Git
- Kostenlos und schnell. Kein CMS-Abo, keine Datenbankabfragen; jede Seite ist statisches HTML.
- Versioniert. Jede Änderung ist ein Commit, den Sie prüfen und zurückrollen können.
- Portabel. Markdown überlebt jede Plattform. Ein Wechsel zu einem anderen Framework bedeutet, den Renderer auszutauschen – nicht, Inhalte zu migrieren.
- Überall bearbeitbar. Im Web-Editor von GitHub, in VS Code oder in einem Git-basierten CMS wie Decap oder Sveltia, wenn jemand lieber ein Formular hat.
Der Haken: Nicht-technische Redakteure brauchen entweder ein bisschen Git oder ein Git-basiertes CMS davor.
Die Ordnerstruktur
Ein Ordner pro Beitrag hält die Bilder neben dem Text, der sie verwendet – und die relativen Links werden auch auf GitHub korrekt in der Vorschau angezeigt:
content/blog/
my-first-post/
index.md
cover.jpg
diagram.pngEin Beitrag beginnt mit 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.Abhängigkeiten
npm install gray-matter remark remark-gfm remark-html highlight.jsgray-mattertrennt Front Matter und Inhalt.remark+remark-gfmparsen Markdown mit den Erweiterungen von GitHub (Tabellen, Aufgabenlisten).remark-htmlmacht HTML daraus.highlight.jshebt Codeblöcke schon beim Build hervor, sodass Leser keinen Highlighter herunterladen.
Die Beiträge laden
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 lässt rohes HTML in Beiträgen durch – in Ordnung, solange Sie der einzige Autor sind. Wenn andere Beiträge einreichen können, sollten Sie weiter bereinigen.
Da das nur in Server Components und generateStaticParams läuft, ist fs kein Problem: Es läuft beim Build, nie im Browser.
Die Seiten
Die Übersicht listet die Beiträge auf; die Detailroute ist für jeden Slug statisch:
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 beim Build
Ein kleines remark-Plugin ersetzt jeden Codeknoten durch hervorgehobenes 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>`,
}
})
}
}Fügen Sie es mit .use(highlightCode) vor remarkHtml ein und importieren Sie ein highlight.js-Theme wie highlight.js/styles/github-dark.css.
Bilder und Video
Next.js liefert statische Dateien aus public/ aus, also müssen die Medien der Beiträge dort landen. Ein winziges Skript, das vor dev und build läuft, kopiert alles, was kein Markdown ist:
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"
}
}Schreiben Sie dann in einem weiteren remark-Plugin relative Bild-URLs (./cover.jpg) auf /blog-media/<slug>/cover.jpg um. Bei der Gelegenheit machen Sie aus Bildern, die auf .mp4- oder .webm-Dateien zeigen, <video controls>-Elemente und aus YouTube-Links eingebettete Videos – Autoren schreiben weiterhin ganz normale Markdown-Bildsyntax.
Nehmen Sie public/blog-media in die .gitignore auf; der Ordner wird erzeugt.
Veröffentlichen
Verbinden Sie das Repository mit einem statischen Hoster – Cloudflare Pages, Netlify oder Vercel – mit npm run build als Build-Befehl. Einen Beitrag zu schreiben heißt dann:
- Einen Ordner und eine
index.mdanlegen. - Committen und pushen.
- Der Hoster baut und deployt in ein paar Minuten.
SEO-Extras, die sich lohnen
generateMetadatamit Titel, Auszug, kanonischer URL und Cover des Beitrags als Open-Graph-Bild.- Eine
sitemap.ts, die jeden Beitrag auflistet. - JSON-LD vom Typ
BlogPosting. - IDs für Überschriften, damit sich Abschnitte verlinken lassen.
All das behandelt technisches SEO für den Next.js App Router.
Wann es nicht mehr reicht
Wenn mehrere nicht-technische Personen täglich veröffentlichen, zeitgesteuerte Beiträge oder redaktionelle Abläufe brauchen, beginnt sich ein Headless CMS zu rechnen. Für eine persönliche Website, ein Studio oder einen Produktblog mit ein paar Beiträgen im Monat ist Markdown in Git kaum zu schlagen.

