Next.js + MDX Documentation Site
Build a documentation site with Next.js 15, MDX, and Tailwind — the architecture behind this cookbook.
Recipe
- Scaffold Next.js with Tailwind and TypeScript:
npx create-next-app@latest docs-site --typescript --tailwind --app cd docs-site - Install MDX tooling:
npm install next-mdx-remote gray-matter - Create a
docs/directory at the project root for.mdfiles, organized asdocs/<section>/<slug>.md. - Create dynamic routes at
app/docs/[section]/[slug]/page.tsx. - Read files with
fs, parse frontmatter withgray-matter, render with<MDXRemote>fromnext-mdx-remote/rsc. - Use
generateStaticParamsto pre-render all docs at build time.
Working Example
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>
);
\}Custom MDX components (optional)
// 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\} />
),
\};Pass them to <MDXRemote source=\{content\} components=\{mdxComponents\} />.
Deep Dive
How It Works
next-mdx-remote/rsc is an async React Server Component that compiles MDX on the server and streams the result. There is no client-side MDX runtime, no bundler step for your content, and no separate build pipeline. At build time, generateStaticParams produces every (section, slug) pair so Next.js statically pre-renders each doc page into HTML.
gray-matter splits each .md file into a frontmatter object and a content string. The content string is passed verbatim to MDXRemote, which handles parsing, transforming (via remark/rehype plugins), and rendering.
Variations
- Syntax highlighting: add
rehype-pretty-codeand Shiki for VS Code-quality highlighting at build time:<MDXRemote source=\{content\} options=\{\{ mdxOptions: \{ rehypePlugins: [[rehypePrettyCode, \{ theme: "github-dark" \}]] \} \}\} /> - GitHub-flavored markdown: add
remark-gfmfor tables, task lists, and strikethrough. - Custom components: map
h1,h2,pre,a,imgto styled React components for consistent prose. - Auto-generated sidebar: walk the
docs/directory at build time withgetAllDocs(), group by section, and render a nav component. - Search: pre-compute a Fuse.js index at build time and ship it as a JSON file the client loads on demand.
- Table of contents: extract headings with
rehype-slugandremark-extract-toc, render a sticky TOC in the article layout.
TypeScript Notes
- Define a
DocFrontmatterinterface and castmatter(raw).data as DocFrontmatter. Considerzodvalidation to fail loudly on malformed frontmatter. - In Next.js 15,
paramsis aPromise— type it asparams: Promise<\{ section: string; slug: string \}>andawaitit before use. - For custom MDX components, import the
MDXComponentstype frommdx/types.
Gotchas
- Curly braces are MDX expressions. Writing
\{foo\}in prose will try to evaluatefooas JavaScript and fail the build. Escape with a backslash\\\{foo\\\}, wrap in backticks, or use HTML entities{. - JSX-like tags break MDX. Writing
<Component />in prose is parsed as JSX. If the component isn't in scope, MDX throws. Wrap in backticks or escape<as<. - Relative
.mdlinks. Markdown links like[see](./other.md)don't match your route structure. Rewrite them at render time with a customacomponent or a remark plugin. MDXRemoteis async. It only works in Server Components. Putting it in a Client Component throws at runtime.fsonly runs server-side. Importinglib/docs.tsfrom a Client Component triggersModule not found: node:fs. Keep it in RSC or route handlers.- Stale content after edits. If you use
export const dynamic = "force-static", newly added docs require a rebuild. Userevalidateor delete the cache if you want on-demand rebuilds. - Frontmatter typos fail silently.
gray-matterreturns whatever keys it finds. Validate withzodor a typed assertion to catch missingtitleororderfields early.
Alternatives
| Tool | Style | Best For |
|---|---|---|
| next-mdx-remote | RSC-friendly, flexible | Custom docs sites in Next.js |
| Contentlayer | Type-safe content layer | Deprecated — unmaintained |
| Velite | Contentlayer successor | Type-safe content with validation |
| Fumadocs | Full docs framework | Batteries-included docs on Next.js |
| Nextra | Docs framework on Next.js | Opinionated, fast setup |
| Mintlify | Hosted SaaS | Zero-config hosted docs |
| Starlight | Astro-based | Docs sites outside the Next.js ecosystem |
FAQs
Why next-mdx-remote over @next/mdx?
@next/mdx compiles .mdx files as routes at build time — great if your content lives in app/. next-mdx-remote compiles MDX from arbitrary strings, so your content can live anywhere (a docs/ folder, a CMS, a database) and your routes stay decoupled from filesystem layout.
Can I use this with a CMS instead of files?
Yes. next-mdx-remote accepts any MDX string. Swap fs.readFile for a fetch against your CMS (Contentful, Sanity, Notion) and pass the returned markdown to <MDXRemote source=\{content\} />.
How do I add syntax highlighting?
Install rehype-pretty-code and shiki, then pass it via mdxOptions.rehypePlugins. It runs at build time, so there is zero client-side cost:
<MDXRemote
source=\{content\}
options=\{\{
mdxOptions: \{
rehypePlugins: [[rehypePrettyCode, \{ theme: "github-dark" \}]],
\},
\}\}
/>How do I generate a sidebar automatically?
Call getAllDocs() in your layout, group results by section, sort within each section by order, and render as a list. Since it's a Server Component, the sidebar is always in sync with the filesystem.
How do I add a table of contents?
Use rehype-slug to add IDs to headings, then walk the MDX AST with remark-extract-toc (or a small custom plugin) and pass the result to a sidebar component. For a simpler approach, extract headings with a regex on the raw markdown.
Gotcha: my build fails with "Expected a closing tag for <Component>"
MDX is trying to parse something in your prose as JSX. Find the offending tag — often a generic like <T> inside a type signature outside a code block — and wrap it in backticks or escape the <. This is the single most common MDX build failure.
Gotcha: curly braces in my code examples don't show up
If your code snippet lives inside a fenced code block (triple-backtick), braces render fine. If you accidentally dropped them into prose, MDX evaluated them as expressions. Move the content into a code fence or escape with a backslash.
How do I handle relative .md links between docs?
Write a custom a component that rewrites href values ending in .md to the matching route:
const a = (\{ href, ...rest \}: any) => \{
const rewritten = href?.endsWith(".md")
? href.replace(/\.md$/, "")
: href;
return <a href=\{rewritten\} \{...rest\} />;
\};Pass it via the components prop to <MDXRemote>.
TypeScript: how do I type the frontmatter?
Define an interface and cast the matter result:
interface DocFrontmatter \{ title: string; order: number \}
const \{ data \} = matter(raw);
const frontmatter = data as DocFrontmatter;For runtime safety, wrap the cast in a zod schema .parse() so bad frontmatter fails the build.
TypeScript: how do I type params in Next.js 15?
In Next.js 15, dynamic route params is a Promise. Type it and await:
interface PageProps \{
params: Promise<\{ section: string; slug: string \}>;
\}
export default async function Page(\{ params \}: PageProps) \{
const \{ section, slug \} = await params;
\}Can I use client-side interactivity inside MDX?
Yes — import a Client Component (marked with "use client") and pass it via the components map. The surrounding MDX stays a Server Component, but the embedded Client Component hydrates normally.
Should I use Fumadocs or Nextra instead?
Use Nextra or Fumadocs if you want a batteries-included docs framework with search, themes, and versioning out of the box. Roll your own with next-mdx-remote if you need custom layouts, unusual content sources, or tight integration with the rest of a Next.js app (like this cookbook).