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
pnpm add next-mdx-remote gray-matter reading-time
pnpm add rehype-pretty-code remark-gfm shikinext-mdx-remote— compila y renderiza MDX en tiempo de ejecucióngray-matter— separa el frontmatter YAML del principio del archivoreading-time— calcula el tiempo de lecturaremark-gfm— tablas, listas de tareas, texto tachadorehype-pretty-code+shiki— resaltado de código
1. El formato del artículo
---
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
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
// 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
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 decódigoen 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
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:
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.
// 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.
// 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í:
<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
Datey falla donde llames a.toLowerCase(). - Sin
remark-gfmno 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 decódigotambié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
Desarrollador Frontend Junior 2026
Lo que realmente necesitas saber para conseguir un trabajo de frontend junior en 2026 y lo que puede esperar, a partir de ofertas reales.
¿SQL o NoSQL?
Responde seis preguntas y te diré qué tipo de base de datos encaja con tu proyecto, y por qué.