Zurück zum Blog

Ein Markdown-Blog in Next.js – ohne CMS und ohne Datenbank

Ein Markdown-Blog in Next.js – ohne CMS und ohne Datenbank

8. Oktober 2026 — Next.js

Veröffentlicht

8. Oktober 2026

Lesezeit

3 Min. Lesezeit

Kategorie

Next.js

Schlagwörter

  • Next.js
  • Markdown
  • Blog
  • remark
  • Static Site

(Intro)Ein Blog, in dem jeder Beitrag eine Markdown-Datei in Ihrem Git-Repository ist. Front Matter, Syntax-Highlighting, Bilder, Video und statische Seiten mit dem Next.js App Router.

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:

text
content/blog/
  my-first-post/
    index.md
    cover.jpg
    diagram.png

Ein Beitrag beginnt mit YAML-Front-Matter:

content/blog/my-first-post/index.md
---
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

bash
npm install gray-matter remark remark-gfm remark-html highlight.js
  • gray-matter trennt Front Matter und Inhalt.
  • remark + remark-gfm parsen Markdown mit den Erweiterungen von GitHub (Tabellen, Aufgabenlisten).
  • remark-html macht HTML daraus.
  • highlight.js hebt Codeblöcke schon beim Build hervor, sodass Leser keinen Highlighter herunterladen.

Die Beiträge laden

lib/blog.ts
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:

app/blog/[slug]/page.tsx
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:

lib/highlight.ts
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:

scripts/copy-blog-media.mjs
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))
  }
}
package.json
{
  "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:

  1. Einen Ordner und eine index.md anlegen.
  2. Committen und pushen.
  3. Der Hoster baut und deployt in ein paar Minuten.

SEO-Extras, die sich lohnen

  • generateMetadata mit 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.

Newsletter

Neue Beiträge per E-Mail

Eine kurze E-Mail, wenn es etwas Neues gibt. Kein Spam, jederzeit abbestellbar.

Ein serverloses Kontaktformular mit Cloudflare Workers und D1
Ein serverloses Kontaktformular mit Cloudflare Workers und D1
Nächster Beitrag