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.
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
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ı.
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
pnpm add next-mdx-remote gray-matter reading-time
pnpm add rehype-pretty-code remark-gfm shikinext-mdx-remote— MDX'i çalışma anında derleyip render edergray-matter— dosyanın başındaki YAML frontmatter'ı ayırırreading-time— okuma süresini hesaplarremark-gfm— tablo, görev listesi, üstü çizili metinrehype-pretty-code+shiki— kod vurgulama
1. Yazının biçimi
---
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.
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
// 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>
);
}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.
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çikodparç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.
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:
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]],
};İç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.
// 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.
// 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:
<Callout title="Dikkat" variant="warning">
Bu bir React bileşeni, Markdown'ın içinde.
</Callout>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
generateStaticParamsyazmazsan 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
Datenesnesine çeviriyor,.toLowerCase()çağırdığın yerde patlıyor. remark-gfmolmadan 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: falsebırakırsankodparç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.