Escritos

MDX con Next.js App Router: la forma correcta de montar un blog

La diferencia entre MDX basado en archivos y MDX remoto, y cuál conviene para un blog. next-mdx-remote/rsc, gray-matter, rehype-pretty-code y los IDs de enlace que rompen los títulos en turco, a partir de la configuración real de este sitio.

Furkan Özay29 de junio de 2025 · 9 min de lectura
czCZAY.DEV // EDITORIALES
TYPE SYSTEM

#Next.js · 9 MIN DE LECTURA

FURKAN ÖZAYVOL. 2025 // #101D

En Next.js hay dos formas distintas de configurar MDX, y sirven para cosas diferentes. Si empiezas sin conocer esta diferencia, montarás la equivocada y el día que tengas que escribir el listado del blog lo harás todo de nuevo.

Este artículo explica primero la diferencia y después monta la correcta para un blog a partir de la configuración real de este sitio.

Las dos formas

Explicación: MDX basado en archivos — @next/mdx

Escribes app/hakkimda/page.mdx y se crea la ruta /hakkimda. El archivo MDX es una página.

Dónde encaja: páginas escasas y escritas a mano: sobre mí, documentación, condiciones de uso.

Explicación: MDX remoto — next-mdx-remote

El MDX es un dato: se lee de un archivo, de un CMS o de una base de datos y luego se renderiza en tiempo de ejecución. No tiene ninguna relación con las rutas.

Dónde encaja: un blog, un sitio de documentación, páginas de producto; en definitiva, cualquier lugar donde el contenido se vaya a listar, ordenar o filtrar.

¿Por qué la segunda para un blog? Porque para montar un listado de blog necesitas los metadatos del contenido: título, fecha, etiquetas, tiempo de lectura. En el enfoque basado en archivos, como cada artículo es una página de React, no existe una forma estándar de leerlos todos en bloque y extraer sus metadatos para listarlos. En el enfoque remoto, en cambio, los artículos ya son texto en el sistema de archivos: los lees, los analizas y los ordenas.

En este sitio hay 11 archivos en content/blog/*.mdx y la página de listado se construye leyéndolos.

Instalación

Bash
pnpm add next-mdx-remote gray-matter reading-time
pnpm add rehype-pretty-code remark-gfm shiki
  • next-mdx-remote — compila y renderiza MDX en tiempo de ejecución
  • gray-matter — separa el frontmatter YAML del principio del archivo
  • reading-time — calcula el tiempo de lectura
  • remark-gfm — tablas, listas de tareas, texto tachado
  • rehype-pretty-code + shiki — resaltado de código

1. El formato del artículo

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. Leer y tipar el frontmatter

Lo importante aquí es la seguridad de tipos: gray-matter te da data: { [key: string]: any }, es decir, un error tipográfico en el frontmatter no se ve hasta el tiempo de ejecución. Concentrar el análisis en un solo lugar y dar valores por defecto lo resuelve.

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

La separación de BlogPostMeta es pequeña pero útil: la página de listado funciona sin cargar en memoria el cuerpo de los 11 artículos.

3. Renderizar la página

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

Consejo: ¿Por qué /rsc?

Importar next-mdx-remote/rsc ejecuta el componente como un componente de servidor: la compilación de MDX ocurre en el servidor y el compilador no se descarga en el navegador. Si lo usas junto con generateStaticParams, los artículos se generan de forma estática en tiempo de compilación; es decir, que sea "remoto" no implica un coste en tiempo de ejecución.

4. Resaltado de código

rehype-pretty-code hace el resaltado en tiempo de compilación usando Shiki; no se descarga ninguna librería de resaltado en el navegador.

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

Las tres opciones son deliberadas:

  • keepBackground: false — Evita que el tema imponga su propio color de fondo. Así el fondo del bloque de código viene de tus tokens de diseño y no aparece una caja desentonada en el tema oscuro.
  • defaultLang: "plaintext" — Un bloque al que se te olvidó indicar el lenguaje no da error.
  • bypassInlineCode: true — No toca los fragmentos de código en línea; los estilas con tu propio CSS.

5. Los títulos en turco rompen los IDs de enlace

Este es el detalle que más tiempo lleva y le pasa a cualquiera que escriba en turco (o en cualquier idioma con caracteres no ASCII).

Los plugins de slug ya hechos convierten el título tal cual en ID. El título ## Sık sorulanlar recibe el ID #sık-sorulanlar: contiene una ı, así que en la barra de direcciones aparece con codificación porcentual, como #s%C4%B1k-sorulanlar. Además de ser feo, al copiarlo y compartirlo puede romperse.

La solución: tu propia función de slug que convierta las letras turcas a ASCII.

Ejemplo: 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}`;
	};
}

Lo conectas a un plugin de rehype:

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

Precaución: La regla principal: una sola función de slug

Al generar la lista de contenidos (TOC), analizas los títulos en otro lugar. Si ese análisis no usa la misma función, los enlaces del TOC no coinciden con los IDs de la página y al hacer clic no ocurre nada.

Es imprescindible llamar al mismo uniqueSlugger en ambos sitios, y además en su versión con contador, porque cuando el mismo título aparece dos veces ambos lados tienen que añadir -1 en el mismo orden.

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. Tus propios componentes

Aquí está el verdadero poder de MDX: puedes meter React dentro de Markdown.

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

Ya puedes usarlo directamente dentro del artículo; todas las cajas de colores de este artículo funcionan así:

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

Consejo: ¿Por qué hace falta un contenedor para la tabla?

Cuando una tabla de Markdown se ensancha, toda la página pasa a poder desplazarse horizontalmente y en móvil el texto también se desplaza. Meter la tabla en su propio contenedor con overflow-x-auto limita el desplazamiento a la tabla.

7. Compartirlo en un monorepo

Si varias de tus aplicaciones van a renderizar MDX, lleva las opciones y la función de slug a un paquete compartido. En este repositorio @czay-dev/mdx hace justo eso: contiene dos archivos, options.ts y slug.ts.

Artículo relacionado: Gestión de monorepos con Turborepo

Trampas

Resumen: Las más frecuentes

  • Si no escribes generateStaticParams, cada artículo se vuelve a compilar en cada solicitud. Esta suele ser la razón de que se crea que el MDX remoto es "lento".
  • Los nombres de los componentes deben empezar con mayúscula. Cuando MDX ve <callout> lo toma por una etiqueta HTML y en silencio no renderiza nada.
  • Pon entre comillas la fecha del frontmatter. YAML convierte una fecha sin comillas en un objeto Date y falla donde llames a .toLowerCase().
  • Sin remark-gfm no hay tablas. Antes de buscar por qué una tabla de Markdown se ve como texto plano, revisa el plugin.
  • No resaltes el código en línea. Si dejas bypassInlineCode: false, los fragmentos de código también toman los colores del tema y se ven abigarrados dentro del párrafo.

Conclusión

Si montas un blog, usa MDX remoto y deja el basado en archivos para las páginas estáticas. Concentra el análisis en un solo módulo, tipa el frontmatter, lleva el resaltado de código al tiempo de compilación y, si escribes en turco, escribe tú mismo la función de slug y úsala después como la misma función tanto en los títulos como en el índice.

Este mismo artículo se renderiza exactamente con esta configuración.

Fuentes

Hoja de ruta y herramienta interactivas

Sigue leyendo