Sitio de documentación Next.js + MDX
Crea un sitio de documentación con Next.js 15, MDX y Tailwind — la arquitectura detrás de este cookbook.
Receta
- Scaffold Next.js con Tailwind y TypeScript:
npx create-next-app@latest docs-site --typescript --tailwind --app cd docs-site - Instala herramientas MDX:
npm install next-mdx-remote gray-matter - Crea un directorio
docs/en la raíz del proyecto para archivos.md, organizados comodocs/<section>/<slug>.md. - Crea rutas dinámicas en
app/docs/[section]/[slug]/page.tsx. - Lee archivos con
fs, analiza el frontmatter congray-matter, renderiza con<MDXRemote>denext-mdx-remote/rsc. - Usa
generateStaticParamspara prerrenderizar todos los documentos en tiempo de compilación.
Ejemplo funcional
lib/docs.ts
import fs from "node:fs/promises";
import path from "node:path";
import matter from "gray-matter";
const DOCS_DIR = path.join(process.cwd(), "docs");
export interface DocFrontmatter \{
title: string;
section: string;
order: number;
tags?: string[];
\}
export interface Doc \{
section: string;
slug: string;
frontmatter: DocFrontmatter;
content: string;
\}
export async function getAllDocs(): Promise<Doc[]> \{
const sections = await fs.readdir(DOCS_DIR, \{ withFileTypes: true \});
const docs: Doc[] = [];
for (const section of sections) \{
if (!section.isDirectory()) continue;
const files = await fs.readdir(path.join(DOCS_DIR, section.name));
for (const file of files) \{
if (!file.endsWith(".md")) continue;
const raw = await fs.readFile(
path.join(DOCS_DIR, section.name, file),
"utf8"
);
const \{ data, content \} = matter(raw);
docs.push(\{
section: section.name,
slug: file.replace(/\.md$/, ""),
frontmatter: data as DocFrontmatter,
content,
\});
\}
\}
return docs.sort((a, b) => a.frontmatter.order - b.frontmatter.order);
\}
export async function getDoc(section: string, slug: string): Promise<Doc | null> \{
try \{
const raw = await fs.readFile(
path.join(DOCS_DIR, section, `$\{slug\}.md`),
"utf8"
);
const \{ data, content \} = matter(raw);
return \{
section,
slug,
frontmatter: data as DocFrontmatter,
content,
\};
\} catch \{
return null;
\}
\}app/docs/[section]/[slug]/page.tsx
import \{ notFound \} from "next/navigation";
import \{ MDXRemote \} from "next-mdx-remote/rsc";
import \{ getAllDocs, getDoc \} from "@/lib/docs";
export async function generateStaticParams() \{
const docs = await getAllDocs();
return docs.map((doc) => (\{
section: doc.section,
slug: doc.slug,
\}));
\}
interface PageProps \{
params: Promise<\{ section: string; slug: string \}>;
\}
export default async function DocPage(\{ params \}: PageProps) \{
const \{ section, slug \} = await params;
const doc = await getDoc(section, slug);
if (!doc) notFound();
return (
<article className="prose prose-slate mx-auto max-w-3xl py-12">
<h1>\{doc.frontmatter.title\}</h1>
<MDXRemote source=\{doc.content\} />
</article>
);
\}Componentes MDX personalizados (opcional)
// components/mdx-components.tsx
import type \{ MDXComponents \} from "mdx/types";
export const mdxComponents: MDXComponents = \{
h2: (props) => <h2 className="mt-8 text-2xl font-bold" \{...props\} />,
code: (props) => (
<code className="rounded bg-slate-100 px-1 py-0.5 text-sm" \{...props\} />
),
\};Pásalos a <MDXRemote source=\{content\} components=\{mdxComponents\} />.
Análisis profundo
Cómo funciona
next-mdx-remote/rsc es un componente async React Server que compila MDX en el servidor y transmite el resultado. No hay tiempo de ejecución MDX del lado del cliente, no hay paso de bundler para tu contenido y no hay pipeline de compilación separada. En tiempo de compilación, generateStaticParams produce cada par (section, slug) para que Next.js prerrenderice estáticamente cada página de documento en HTML.
gray-matter divide cada archivo .md en un objeto de frontmatter y una cadena de contenido. La cadena de contenido se pasa verbatim a MDXRemote, que maneja el análisis, la transformación (a través de plugins remark/rehype) y el renderizado.
Variaciones
- Resaltado de sintaxis: añade
rehype-pretty-codey Shiki para resaltado de calidad de VS Code en tiempo de compilación:<MDXRemote source=\{content\} options=\{\{ mdxOptions: \{ rehypePlugins: [[rehypePrettyCode, \{ theme: "github-dark" \}]] \} \}\} /> - Markdown con sabor GitHub: añade
remark-gfmpara tablas, listas de tareas y tachado. - Componentes personalizados: mapea
h1,h2,pre,a,imga componentes React estilizados para una prosa consistente. - Barra lateral autogenerada: camina por el directorio
docs/en tiempo de compilación congetAllDocs(), agrupa por sección y renderiza un componente de navegación. - Búsqueda: precomputa un índice Fuse.js en tiempo de compilación y envíalo como un archivo JSON que el cliente carga bajo demanda.
- Tabla de contenidos: extrae encabezados con
rehype-slugyremark-extract-toc, renderiza una tabla de contenidos pegajosa en el diseño del artículo.
Notas de TypeScript
- Define una interfaz
DocFrontmattery lanzamatter(raw).data as DocFrontmatter. Considera la validación dezodpara fallar ruidosamente en el frontmatter malformado. - En Next.js 15,
paramses unaPromise— escríbela comoparams: Promise<\{ section: string; slug: string \}>yawaitantes de usarla. - Para componentes MDX personalizados, importa el tipo
MDXComponentsdemdx/types.
Trampas
- Las llaves son expresiones MDX. Escribir
\{foo\}en prosa intentará evaluarfoocomo JavaScript y fallará en la compilación. Escapa con una barra invertida\\\{foo\\\}, envuelve en backticks o usa entidades HTML{. - Las etiquetas similares a JSX rompen MDX. Escribir
<Component />en prosa se analiza como JSX. Si el componente no está en scope, MDX lanza. Envuelve en backticks o escapa<como<. - Enlaces relativos de
.md. Los enlaces de Markdown como[see](./other.md)no coinciden con tu estructura de rutas. Reescríbelos en tiempo de renderizado con un componenteapersonalizado o un plugin remark. MDXRemotees async. Solo funciona en Server Components. Ponerlo en un Client Component lanza en tiempo de ejecución.fssolo se ejecuta del lado del servidor. Importarlib/docs.tsdesde un Client Component activaModule not found: node:fs. Mantenlo en RSC o manejadores de rutas.- Contenido obsoleto después de ediciones. Si usas
export const dynamic = "force-static", los documentos recién añadidos requieren una reconstrucción. Usarevalidateo elimina la caché si quieres reconstrucciones bajo demanda. - Errores tipográficos en frontmatter fallan silenciosamente.
gray-matterdevuelve las claves que encuentra. Valida conzodo una aserción tipada para detectar los campostitleuorderfaltantes temprano.
Alternativas
| Herramienta | Estilo | Mejor para |
|---|---|---|
| next-mdx-remote | Amigable con RSC, flexible | Sitios de documentación personalizados en Next.js |
| Contentlayer | Capa de contenido type-safe | Deprecado — sin mantenimiento |
| Velite | Sucesor de Contentlayer | Contenido type-safe con validación |
| Fumadocs | Marco de documentación completo | Documentación lista para usar en Next.js |
| Nextra | Marco de documentación en Next.js | Opinado, configuración rápida |
| Mintlify | SaaS alojado | Documentos alojados sin configuración |
| Starlight | Basado en Astro | Sitios de documentación fuera del ecosistema Next.js |
Preguntas frecuentes
¿Por qué next-mdx-remote en lugar de @next/mdx?
@next/mdx compila archivos .mdx como rutas en tiempo de compilación — excelente si tu contenido vive en app/. next-mdx-remote compila MDX de cadenas arbitrarias, por lo que tu contenido puede vivir en cualquier lugar (una carpeta docs/, un CMS, una base de datos) y tus rutas permanecen desacopladas del diseño del sistema de archivos.
¿Puedo usar esto con un CMS en lugar de archivos?
Sí. next-mdx-remote acepta cualquier cadena MDX. Intercambia fs.readFile por un fetch contra tu CMS (Contentful, Sanity, Notion) y pasa el markdown devuelto a <MDXRemote source=\{content\} />.
¿Cómo añado resaltado de sintaxis?
Instala rehype-pretty-code y shiki, luego pásalo a través de mdxOptions.rehypePlugins. Se ejecuta en tiempo de compilación, por lo que no hay costo en el lado del cliente:
<MDXRemote
source=\{content\}
options=\{\{
mdxOptions: \{
rehypePlugins: [[rehypePrettyCode, \{ theme: "github-dark" \}]],
\},
\}\}
/>¿Cómo genero una barra lateral automáticamente?
Llama a getAllDocs() en tu layout, agrupa los resultados por section, ordena dentro de cada sección por order y renderiza como lista. Como es un Server Component, la barra lateral siempre está sincronizada con el sistema de archivos.
¿Cómo añado una tabla de contenidos?
Usa rehype-slug para añadir IDs a los encabezados, luego camina por el AST de MDX con remark-extract-toc (o un pequeño plugin personalizado) y pasa el resultado a un componente de barra lateral. Para un enfoque más simple, extrae encabezados con una regex sobre el markdown sin procesar.
Trampa: mi compilación falla con "Expected a closing tag for <Component>"
MDX está intentando analizar algo en tu prosa como JSX. Encuentra la etiqueta ofensiva — a menudo un genérico como <T> dentro de una firma de tipo fuera de un bloque de código — y envuélvela en backticks o escapa el <. Esta es la falla de compilación MDX más común.
Trampa: las llaves en mis ejemplos de código no aparecen
Si tu fragmento de código vive dentro de un bloque de código cercado (triple-backtick), las llaves se renderizan bien. Si accidentalmente las dejaste caer en prosa, MDX las evaluó como expresiones. Mueve el contenido a un cerco de código o escapa con una barra invertida.
¿Cómo manejo enlaces relativos de .md entre documentos?
Escribe un componente a personalizado que reescriba los valores de href terminados en .md a la ruta coincidente:
const a = (\{ href, ...rest \}: any) => \{
const rewritten = href?.endsWith(".md")
? href.replace(/\.md$/, "")
: href;
return <a href=\{rewritten\} \{...rest\} />;
\};Pásalo a través de la prop components a <MDXRemote>.
TypeScript: ¿cómo escribo el frontmatter?
Define una interfaz y lanza el resultado de matter:
interface DocFrontmatter \{ title: string; order: number \}
const \{ data \} = matter(raw);
const frontmatter = data as DocFrontmatter;Para seguridad en tiempo de ejecución, envuelve la proyección en un esquema zod .parse() para que el frontmatter incorrecto falle en la compilación.
TypeScript: ¿cómo escribo params en Next.js 15?
En Next.js 15, la ruta dinámica params es una Promise. Escríbela y espera:
interface PageProps \{
params: Promise<\{ section: string; slug: string \}>;
\}
export default async function Page(\{ params \}: PageProps) \{
const \{ section, slug \} = await params;
\}¿Puedo usar interactividad del lado del cliente dentro de MDX?
Sí — importa un Client Component (marcado con "use client") y pásalo a través del mapa components. El MDX circundante permanece como un Server Component, pero el Client Component incrustado se hidrata normalmente.
¿Debería usar Fumadocs o Nextra en su lugar?
Usa Nextra o Fumadocs si quieres un marco de documentación listo para usar con búsqueda, temas y versionado fuera de la caja. Construye el tuyo con next-mdx-remote si necesitas diseños personalizados, fuentes de contenido inusuales o integración estrecha con el resto de una aplicación Next.js (como este cookbook).