Artikel

MDX mit dem Next.js App Router: der richtige Weg, einen Blog aufzubauen

Der Unterschied zwischen dateibasiertem und Remote MDX und welches für einen Blog das richtige ist. next-mdx-remote/rsc, gray-matter, rehype-pretty-code und Anker-IDs, die durch türkische Überschriften kaputtgehen – anhand des funktionierenden Setups dieser Seite.

Furkan Özay29. Juni 2025 · 9 Min. Lesezeit
czCZAY.DEV // EDITORIALDE
TYPE SYSTEM

#Next.js · 9 MIN LESEZEIT

FURKAN ÖZAYVOL. 2025 // #101D

Es gibt in Next.js zwei getrennte Wege, MDX einzurichten, und beide sind für unterschiedliche Aufgaben gedacht. Wenn du diesen Unterschied nicht kennst, baust du den falschen und machst an dem Tag, an dem du die Blogliste schreibst, alles noch einmal von vorn.

Dieser Beitrag erklärt zuerst den Unterschied und richtet dann anhand des funktionierenden Setups dieser Seite den für einen Blog richtigen Weg ein.

Die zwei Wege

Erklärung: Dateibasiertes MDX – @next/mdx

Du schreibst app/hakkimda/page.mdx, und die Route /hakkimda entsteht. Die MDX-Datei ist eine Seite.

Richtig für: wenige, von Hand geschriebene Seiten – Über mich, Dokumentation, Nutzungsbedingungen.

Erklärung: Remote MDX – next-mdx-remote

MDX ist Daten: Es wird aus einer Datei, einem CMS oder einer Datenbank gelesen und dann zur Laufzeit gerendert. Mit der Route hat es nichts zu tun.

Richtig für: Blog, Dokumentationsseite, Produktseiten – kurz gesagt überall, wo Inhalte aufgelistet, sortiert und gefiltert werden.

Warum für einen Blog das zweite? Weil du für eine Blogliste die Metadaten der Inhalte brauchst: Titel, Datum, Tags, Lesezeit. Beim dateibasierten Ansatz ist jeder Beitrag eine React-Seite, und es gibt keinen Standardweg, alle Beiträge gesammelt einzulesen und ihre Metadaten zu extrahieren, um sie aufzulisten. Beim Remote-Ansatz sind die Beiträge dagegen ohnehin Texte im Dateisystem – du liest sie, parst sie und sortierst sie.

Auf dieser Seite liegen unter content/blog/*.mdx 11 Dateien, und die Listenseite wird aus ihnen aufgebaut.

Installation

Bash
pnpm add next-mdx-remote gray-matter reading-time
pnpm add rehype-pretty-code remark-gfm shiki
  • next-mdx-remote – kompiliert MDX zur Laufzeit und rendert es
  • gray-matter – trennt das YAML-Frontmatter am Dateianfang ab
  • reading-time – berechnet die Lesezeit
  • remark-gfm – Tabellen, Aufgabenlisten, durchgestrichener Text
  • rehype-pretty-code + shiki – Code-Highlighting

1. Das Format eines Beitrags

markdown
---
title: "Yazının başlığı"
description: "Liste sayfasında ve meta etiketinde görünen özet"
date: "2026-09-02"
tags:
  - Next.js
  - MDX
published: true
featured: false
---
 
Yazının gövdesi buradan başlıyor.

2. Frontmatter lesen und typisieren

Hier geht es vor allem um Typsicherheit: gray-matter liefert dir data: { [key: string]: any }, ein Tippfehler im Frontmatter fällt also erst zur Laufzeit auf. Das löst du, indem du das Parsen an einer einzigen Stelle bündelst und Standardwerte vergibst.

Beispiel: lib/blog.ts

TypeScript
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import readingTime from "reading-time";
 
const BLOG_DIR = path.join(process.cwd(), "content/blog");
 
export type BlogPost = {
	slug: string;
	title: string;
	description: string;
	date: string;
	updated?: string;
	tags: string[];
	published: boolean;
	featured: boolean;
	readingTime: string;
	content: string;
};
 
/** Üstveri, gövde olmadan — liste sayfaları bunu kullanıyor. */
export type BlogPostMeta = Omit<BlogPost, "content">;
 
function parsePost(slug: string, raw: string): BlogPost {
	const { data, content } = matter(raw);
	const stats = readingTime(content);
 
	return {
		slug,
		title: data.title ?? "",
		description: data.description ?? "",
		date: data.date ?? "",
		updated: data.updated ?? undefined,
		tags: data.tags ?? [],
		published: data.published ?? true,
		featured: data.featured ?? false,
		readingTime: `${Math.ceil(stats.minutes)} dk`,
		content,
	};
}
 
export function getAllPosts(): BlogPostMeta[] {
	if (!fs.existsSync(BLOG_DIR)) return [];
 
	return fs
		.readdirSync(BLOG_DIR)
		.filter((f) => f.endsWith(".mdx") || f.endsWith(".md"))
		.map((file) => {
			const slug = file.replace(/\.mdx?$/, "");
			const raw = fs.readFileSync(path.join(BLOG_DIR, file), "utf-8");
			const { content, ...meta } = parsePost(slug, raw);
			return meta;
		})
		.filter((post) => post.published)
		.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}

Die Trennung in BlogPostMeta ist klein, aber nützlich: Die Listenseite funktioniert, ohne die Inhalte von 11 Beiträgen in den Speicher zu laden.

3. Die Seite rendern

TSX
// app/blog/[slug]/page.tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import { notFound } from "next/navigation";
import { getPost, getAllPosts } from "@/lib/blog";
import { mdxComponents } from "@/components/blog/mdx-components";
import { mdxOptions } from "@/lib/mdx-options";
 
export async function generateStaticParams() {
	return getAllPosts().map((post) => ({ slug: post.slug }));
}
 
export default async function PostPage({
	params,
}: {
	params: Promise<{ slug: string }>;
}) {
	const { slug } = await params;
	const post = getPost(slug);
	if (!post) notFound();
 
	return (
		<article className="prose-mdx">
			<MDXRemote
				source={post.content}
				components={mdxComponents}
				options={{ mdxOptions }}
			/>
		</article>
	);
}

Tipp: Warum /rsc?

Der Import next-mdx-remote/rsc führt die Komponente als Server Component aus: Die MDX-Kompilierung passiert auf dem Server, der Compiler wird nicht in den Browser geladen. In Kombination mit generateStaticParams werden die Beiträge zur Build-Zeit statisch generiert – „Remote" bedeutet also nicht, dass zur Laufzeit Kosten entstehen.

4. Code-Highlighting

rehype-pretty-code übernimmt das Highlighting mit Shiki zur Build-Zeit; keine Highlighting-Bibliothek gelangt in den Browser.

Beispiel: lib/mdx-options.ts

TypeScript
import rehypePrettyCode, {
	type Options as PrettyCodeOptions,
} from "rehype-pretty-code";
import remarkGfm from "remark-gfm";
 
const prettyCodeOptions: PrettyCodeOptions = {
	theme: { light: "github-light", dark: "github-dark-default" },
	keepBackground: false,
	defaultLang: "plaintext",
	bypassInlineCode: true,
};
 
export const mdxOptions = {
	remarkPlugins: [remarkGfm],
	rehypePlugins: [[rehypePrettyCode, prettyCodeOptions]],
};

Alle drei Einstellungen sind bewusst gewählt:

  • keepBackground: false – Verhindert, dass das Theme seine eigene Hintergrundfarbe setzt. So kommt der Hintergrund des Codeblocks aus deinen Design-Tokens, und im Dark Mode entsteht kein unpassender Kasten.
  • defaultLang: "plaintext" – Ein Block, bei dem du die Sprache vergessen hast, löst keinen Fehler aus.
  • bypassInlineCode: true – Fasst Inline-Code nicht an; den formatierst du mit deinem eigenen CSS.

5. Türkische Überschriften zerstören Anker-IDs

Das ist das Detail, das am meisten Zeit kostet, und es trifft jeden, der auf Türkisch schreibt.

Fertige Slug-Plugins wandeln die Überschrift unverändert in eine ID um. Die Überschrift ## Sık sorulanlar bekommt die ID #sık-sorulanlar – darin steckt ein ı, also erscheint in der Adressleiste per Prozent-Kodierung #s%C4%B1k-sorulanlar. Abgesehen davon, dass das hässlich ist, kann der Link beim Kopieren und Teilen kaputtgehen.

Die Lösung: eine eigene Slug-Funktion, die türkische Buchstaben auf ASCII abbildet.

Beispiel: lib/slug.ts

TypeScript
const TR_MAP: Record<string, string> = {
	ç: "c", Ç: "c",
	ğ: "g", Ğ: "g",
	ı: "i", İ: "i", I: "i",
	ö: "o", Ö: "o",
	ş: "s", Ş: "s",
	ü: "u", Ü: "u",
};
 
export function slugify(text: string): string {
	return text
		.replace(/[çÇğĞıİIöÖşŞüÜ]/g, (ch) => TR_MAP[ch] ?? ch)
		.toLowerCase()
		.trim()
		.replace(/[^a-z0-9\s-]/g, "")
		.replace(/\s+/g, "-")
		.replace(/--+/g, "-")
		.replace(/^-+|-+$/g, "");
}
 
/** Aynı başlık iki kez geçerse ikincisine -1 ekler. */
export function uniqueSlugger() {
	const counts = new Map<string, number>();
	return (text: string): string => {
		const base = slugify(text) || "section";
		const n = counts.get(base) ?? 0;
		counts.set(base, n + 1);
		return n === 0 ? base : `${base}-${n}`;
	};
}

Das hängst du an ein rehype-Plugin:

TypeScript
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
 
function rehypeAsciiSlug() {
	return (tree) => {
		const next = uniqueSlugger();
		const walk = (node) => {
			if (node.type === "element" && HEADING_TAGS.has(node.tagName)) {
				const props = (node.properties ??= {});
				if (typeof props.id !== "string" || !props.id) {
					props.id = next(extractText(node));
				}
			}
			node.children?.forEach(walk);
		};
		walk(tree);
	};
}
 
export const mdxOptions = {
	remarkPlugins: [remarkGfm],
	rehypePlugins: [rehypeAsciiSlug, [rehypePrettyCode, prettyCodeOptions]],
};

Achtung: Die eigentliche Regel: eine einzige Slug-Funktion

Wenn du das Inhaltsverzeichnis (TOC) erzeugst, parst du die Überschriften an einer anderen Stelle. Verwendet dieses Parsen nicht dieselbe Funktion, stimmen die Links im TOC nicht mit den IDs auf der Seite überein, und beim Klick passiert nichts.

Derselbe uniqueSlugger muss an beiden Stellen aufgerufen werden – und zwar in der Variante mit Zähler, denn wenn dieselbe Überschrift zweimal vorkommt, müssen beide Seiten in derselben Reihenfolge -1 anhängen.

TypeScript
// lib/blog.ts — TOC üretimi, aynı fonksiyonla
export function extractHeadings(content: string) {
	const matches = content.match(/^## .+$/gm) ?? [];
	const next = uniqueSlugger();
	return matches.map((line) => {
		const text = line.replace(/^## /, "").trim();
		return { id: next(text), text };
	});
}

6. Eigene Komponenten

Hier liegt die eigentliche Stärke von MDX: Du kannst React in Markdown einbetten.

TSX
// components/blog/mdx-components.tsx
import { Callout } from "./callout";
 
export const mdxComponents = {
	Callout,
	// Geniş tablolar sayfayı yatay kaydırmasın diye kendi kabında kayıyor
	table: ({ children }: { children: React.ReactNode }) => (
		<div className="my-6 overflow-x-auto rounded-xl border border-border">
			<table className="w-full text-sm">{children}</table>
		</div>
	),
};

Jetzt kannst du sie direkt im Beitrag verwenden – alle farbigen Kästen in diesem Beitrag funktionieren so:

MDX
<Callout title="Dikkat" variant="warning">
	Bu bir React bileşeni, Markdown'ın içinde.
</Callout>

Tipp: Warum der Tabellen-Container?

Wird eine Markdown-Tabelle breiter, macht sie die gesamte Seite horizontal scrollbar, und auf Mobilgeräten verschiebt sich auch der Text. Packst du die Tabelle in einen eigenen Container mit overflow-x-auto, bleibt das Scrollen auf die Tabelle begrenzt.

7. Im Monorepo teilen

Wenn mehrere deiner Anwendungen MDX rendern, lege die Optionen und die Slug-Funktion in ein gemeinsames Paket. In diesem Repository macht das @czay-dev/mdx – es enthält zwei Dateien, options.ts und slug.ts.

Passender Beitrag: Monorepo-Verwaltung mit Turborepo

Stolperfallen

Zusammenfassung: Häufige Fälle

  • Wenn du generateStaticParams nicht schreibst, wird jeder Beitrag bei jeder Anfrage neu kompiliert. Das ist meist der Grund, warum Remote MDX für „langsam" gehalten wird.
  • Komponentennamen müssen mit einem Großbuchstaben beginnen. Sieht MDX <callout>, hält es das für ein HTML-Tag und rendert stillschweigend nichts.
  • Setze das Datum im Frontmatter in Anführungszeichen. YAML wandelt ein Datum ohne Anführungszeichen in ein Date-Objekt um, und dort, wo du .toLowerCase() aufrufst, knallt es.
  • Ohne remark-gfm keine Tabellen. Bevor du dich fragst, warum eine Markdown-Tabelle als einfacher Text erscheint, prüfe das Plugin.
  • Inline-Code nicht highlighten. Lässt du bypassInlineCode: false, bekommen auch Code-Schnipsel die Theme-Farben und wirken im Absatz bunt gescheckt.

Fazit

Wenn du einen Blog aufbaust, nimm Remote MDX und überlasse das dateibasierte MDX den statischen Seiten. Bündle das Parsen in einem Modul, typisiere das Frontmatter, verlege das Code-Highlighting in die Build-Zeit – und wenn du auf Türkisch schreibst, schreibe die Slug-Funktion selbst und verwende sie dann sowohl für die Überschriften als auch für das Inhaltsverzeichnis als dieselbe Funktion.

Dieser Beitrag selbst wird genau mit diesem Setup gerendert.

Quellen

Interaktive Roadmap & Tool

Als Nächstes lesen