Next.js App Router ile MDX: blog kurmanın doğru yolu

Next.js App Router ile MDX: blog kurmanın doğru yolu

Dosya tabanlı MDX ile remote MDX arasındaki fark ve blog için hangisi doğru? next-mdx-remote/rsc, gray-matter, rehype-pretty-code ve Türkçe başlıkların bozduğu bağlantı kimlikleri — bu sitenin çalışan kurulumu üzerinden.

Furkan Özay·29 Haziran 2025·8 dk okuma·2 Eyl 2026'de güncellendi

Next.js'te MDX kurmanın iki ayrı yolu var ve ikisi farklı işler için. Bu ayrımı bilmeden başlarsan yanlış olanı kurup, blog listesi yazacağın gün her şeyi baştan yapıyorsun.

Bu yazı önce ayrımı anlatıyor, sonra blog için doğru olanı bu sitenin çalışan kurulumu üzerinden kuruyor.

İki yol

💬Dosya tabanlı MDX — @next/mdx

app/hakkimda/page.mdx yazarsın, /hakkimda rotası oluşur. MDX dosyası bir sayfadır.

Doğru olduğu yer: Sayısı az, elle yazılan sayfalar — hakkımda, dokümantasyon, kullanım koşulları.

💬Remote MDX — next-mdx-remote

MDX bir veridir: dosyadan, CMS'ten ya da veritabanından okunur, sonra çalışma anında render edilir. Rota ile hiçbir bağı yoktur.

Doğru olduğu yer: Blog, dokümantasyon sitesi, ürün sayfaları — kısacası içeriğin listeleneceği, sıralanacağı, filtreleneceği her yer.

Blog için neden ikincisi? Çünkü bir blog listesi kurmak için içeriğin üstverisine ihtiyacın var: başlık, tarih, etiketler, okuma süresi. Dosya tabanlı yaklaşımda her yazı bir React sayfası olduğu için, tüm yazıları listelemek üzere onları toplu okuyup üstverilerini çıkarmanın standart bir yolu yok. Remote yaklaşımda ise yazılar zaten dosya sisteminde duran metinler — okur, ayrıştırır, sıralarsın.

Bu sitede content/blog/*.mdx altında 11 dosya var ve liste sayfası bunları okuyarak kuruluyor.

Kurulum

Bash
pnpm add next-mdx-remote gray-matter reading-time
pnpm add rehype-pretty-code remark-gfm shiki
  • next-mdx-remote — MDX'i çalışma anında derleyip render eder
  • gray-matter — dosyanın başındaki YAML frontmatter'ı ayırır
  • reading-time — okuma süresini hesaplar
  • remark-gfm — tablo, görev listesi, üstü çizili metin
  • rehype-pretty-code + shiki — kod vurgulama

1. Yazının biçimi

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'ı okumak ve tiplemek

Buradaki asıl iş tip güvenliği: gray-matter sana data: { [key: string]: any } veriyor, yani frontmatter'da yaptığın yazım hatası çalışma anına kadar görünmüyor. Ayrıştırmayı tek bir yerde toplayıp varsayılan değer vermek bunu çözüyor.

🧪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());
}

BlogPostMeta ayrımı küçük ama işe yarıyor: liste sayfası 11 yazının gövdesini belleğe almadan çalışıyor.

3. Sayfayı render etmek

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>
	);
}
💡Neden /rsc?

next-mdx-remote/rsc içe aktarımı bileşeni bir sunucu bileşeni olarak çalıştırıyor: MDX derlemesi sunucuda oluyor, tarayıcıya derleyici inmiyor. generateStaticParams ile birlikte kullanınca yazılar derleme anında statik üretiliyor — yani "remote" olması çalışma anında maliyet demek değil.

4. Kod vurgulama

rehype-pretty-code, Shiki'yi kullanarak vurgulamayı derleme anında yapıyor; tarayıcıya hiçbir vurgulama kütüphanesi inmiyor.

🧪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]],
};

Üç ayarın üçü de bilinçli:

  • keepBackground: false — Temanın kendi arka plan rengini basmasını engelliyor. Böylece kod bloğunun zemini senin tasarım tokenlarından geliyor ve karanlık temada uyumsuz bir kutu oluşmuyor.
  • defaultLang: "plaintext" — Dil belirtmeyi unuttuğun blok hata vermiyor.
  • bypassInlineCode: true — Satır içi kod parçalarına dokunmuyor; onları kendi CSS'inle biçimlendiriyorsun.

5. Türkçe başlıklar bağlantı kimliklerini bozuyor

En çok zaman aldıran ayrıntı bu ve Türkçe yazan herkesin başına geliyor.

Hazır slug eklentileri başlığı olduğu gibi kimliğe çeviriyor. ## Sık sorulanlar başlığı #sık-sorulanlar kimliğini alıyor — içinde ı var, yani adres çubuğunda yüzde kodlamasıyla #s%C4%B1k-sorulanlar görünüyor. Çirkin olması bir yana, kopyalayıp paylaşınca kırılabiliyor.

Çözüm: Türkçe harfleri ASCII'ye katlayan kendi slug fonksiyonun.

🧪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}`;
	};
}

Bunu bir rehype eklentisine bağlıyorsun:

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]],
};
🚨Asıl kural: tek bir slug fonksiyonu

İçindekiler (TOC) listesini üretirken başlıkları ayrı bir yerde ayrıştırıyorsun. O ayrıştırma aynı fonksiyonu kullanmazsa, TOC'taki bağlantılar sayfadaki kimliklerle eşleşmiyor ve tıklandığında hiçbir şey olmuyor.

Aynı uniqueSlugger'ı iki yerde de çağırmak şart — üstelik sayaçlı hâlini, çünkü aynı başlık iki kez geçtiğinde iki tarafın da aynı sırayla -1 eklemesi gerekiyor.

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. Kendi bileşenlerin

MDX'in asıl gücü burada: Markdown'ın içine React koyabiliyorsun.

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>
	),
};

Artık yazının içinde doğrudan kullanabiliyorsun — bu yazıdaki bütün renkli kutular böyle çalışıyor:

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

Markdown tablosu genişlediğinde sayfanın tamamını yatay kaydırılabilir hâle getiriyor ve mobilde metin de kayıyor. Tabloyu overflow-x-auto olan kendi kabına almak, kaymayı tabloyla sınırlıyor.

7. Monorepo'da paylaşmak

Birden fazla uygulaman MDX render edecekse seçenekleri ve slug fonksiyonunu paylaşılan bir pakete al. Bu depoda @czay-dev/mdx tam olarak bunu yapıyor — içinde iki dosya var, options.ts ve slug.ts.

İlgili yazı: Turborepo ile monorepo yönetimi

Tuzaklar

📋Sık karşılaşılanlar
  • generateStaticParams yazmazsan her yazı her istekte yeniden derleniyor. Remote MDX'in "yavaş" sanılmasının sebebi genelde bu.
  • Bileşen adları büyük harfle başlamalı. MDX <callout> gördüğünde bunu bir HTML etiketi sanıyor ve sessizce hiçbir şey render etmiyor.
  • Frontmatter'daki tarihi tırnak içine al. YAML tırnaksız tarihi Date nesnesine çeviriyor, .toLowerCase() çağırdığın yerde patlıyor.
  • remark-gfm olmadan tablo yok. Markdown tablosu yazıp neden düz metin göründüğünü aramadan önce eklentiyi kontrol et.
  • Satır içi kodu vurgulatma. bypassInlineCode: false bırakırsan kod parçaları da tema renklerini alıyor ve paragraf içinde alacalı görünüyor.

Sonuç

Blog kuruyorsan remote MDX kullan, dosya tabanlı olanı statik sayfalara bırak. Ayrıştırmayı tek bir modülde topla, frontmatter'ı tiple, kod vurgulamayı derleme anına al — ve Türkçe yazıyorsan slug fonksiyonunu kendin yaz, sonra da onu hem başlıklarda hem içindekilerde aynı fonksiyon olarak kullan.

Bu yazının kendisi de tam olarak bu kurulumla render ediliyor.

Kaynaklar

Bu yazıyı faydalı bulduysan paylaşabilirsin.

Furkan Özay

Yazılım geliştirici ve eğitmen; Next.js, React ve altyapı üzerine yazıyor.

czay.dev

Bunu da oku

Yorumlar