Next.js + MDX Documentation Site
Build a documentation site with Next.js 15, MDX, and Tailwind - the architecture behind this cookbook.
Search across all documentation pages
Build a documentation site with Next.js 15, MDX, and Tailwind - the architecture behind this cookbook.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
npx create-next-app@latest docs-site --typescript --tailwind --app
cd docs-sitenpm install next-mdx-remote gray-matterdocs/ directory at the project root for .md files, organized as docs/<section>/<slug>.md.app/docs/[section]/[slug]/page.tsx.fs, parse frontmatter with gray-matter, render with <MDXRemote> from next-mdx-remote/rsc.generateStaticParams to pre-render all docs at build time.lib/docs.tsimport 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.tsximport \{ 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>
);
\}// 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\} />.
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.
rehype-pretty-code and Shiki for VS Code-quality highlighting at build time:
<MDXRemote
source=\{content\}
options=\{\{ mdxOptions: \{ rehypePlugins: [[rehypePrettyCode, \{ theme: "github-dark" \}]] \} \}\}
/>remark-gfm for tables, task lists, and strikethrough.h1, h2, pre, a, img to styled React components for consistent prose.docs/ directory at build time with getAllDocs(), group by section, and render a nav component.rehype-slug and remark-extract-toc, render a sticky TOC in the article layout.DocFrontmatter interface and cast matter(raw).data as DocFrontmatter. Consider zod validation to fail loudly on malformed frontmatter.params is a Promise - type it as params: Promise<\{ section: string; slug: string \}> and await it before use.MDXComponents type from mdx/types.\{foo\} in prose will try to evaluate foo as JavaScript and fail the build. Escape with a backslash \\\{foo\\\}, wrap in backticks, or use HTML entities {.<Component /> in prose is parsed as JSX. If the component isn't in scope, MDX throws. Wrap in backticks or escape < as <..md links. Markdown links like [see](./other.md) don't match your route structure. Rewrite them at render time with a custom a component or a remark plugin.MDXRemote is async. It only works in Server Components. Putting it in a Client Component throws at runtime.fs only runs server-side. Importing lib/docs.ts from a Client Component triggers Module not found: node:fs. Keep it in RSC or route handlers.export const dynamic = "force-static", newly added docs require a rebuild. Use revalidate or delete the cache if you want on-demand rebuilds.gray-matter returns whatever keys it finds. Validate with zod or a typed assertion to catch missing title or order fields early.| 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 |
@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.
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\} />.
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" \}]],
\},
\}\}
/>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.
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.
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.
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.
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>.
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.
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;
\}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.
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).
Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥