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
pnpm add next-mdx-remote gray-matter reading-time
pnpm add rehype-pretty-code remark-gfm shikinext-mdx-remote– kompiliert MDX zur Laufzeit und rendert esgray-matter– trennt das YAML-Frontmatter am Dateianfang abreading-time– berechnet die Lesezeitremark-gfm– Tabellen, Aufgabenlisten, durchgestrichener Textrehype-pretty-code+shiki– Code-Highlighting
1. Das Format eines Beitrags
---
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
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
// 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
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-Codenicht 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
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:
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.
// 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.
// 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:
<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
generateStaticParamsnicht 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-gfmkeine 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 auchCode-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
Junior Frontend Developer 2026
Was du wirklich wissen musst, um 2026 einen Junior-Frontend-Job zu bekommen, und was warten kann – abgeleitet aus echten Stellenanzeigen.
SQL oder NoSQL?
Beantworte sechs Fragen, und ich sage dir, welche Art von Datenbank zu deinem Projekt passt und warum.