React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

mdxdocumentationmarkdowngray-matterrscstatic-generation

Next.js + MDX Documentation Site

Build a documentation site with Next.js 15, MDX, and Tailwind — the architecture behind this cookbook.

Recipe

  1. Scaffold Next.js with Tailwind and TypeScript:
    npx create-next-app@latest docs-site --typescript --tailwind --app
    cd docs-site
  2. Install MDX tooling:
    npm install next-mdx-remote gray-matter
  3. Create a docs/ directory at the project root for .md files, organized as docs/<section>/<slug>.md.
  4. Create dynamic routes at app/docs/[section]/[slug]/page.tsx.
  5. Read files with fs, parse frontmatter with gray-matter, render with <MDXRemote> from next-mdx-remote/rsc.
  6. Use generateStaticParams to 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-code and Shiki for VS Code-quality highlighting at build time:
    <MDXRemote
      source=\{content\}
      options=\{\{ mdxOptions: \{ rehypePlugins: [[rehypePrettyCode, \{ theme: "github-dark" \}]] \} \}\}
    />
  • GitHub-flavored markdown: add remark-gfm for tables, task lists, and strikethrough.
  • Custom components: map h1, h2, pre, a, img to styled React components for consistent prose.
  • Auto-generated sidebar: walk the docs/ directory at build time with getAllDocs(), 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-slug and remark-extract-toc, render a sticky TOC in the article layout.

TypeScript Notes

  • Define a DocFrontmatter interface and cast matter(raw).data as DocFrontmatter. Consider zod validation to fail loudly on malformed frontmatter.
  • In Next.js 15, params is a Promise — type it as params: Promise<\{ section: string; slug: string \}> and await it before use.
  • For custom MDX components, import the MDXComponents type from mdx/types.

Gotchas

  1. Curly braces are MDX expressions. Writing \{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 &#123;.
  2. 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 &lt;.
  3. Relative .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.
  4. MDXRemote is async. It only works in Server Components. Putting it in a Client Component throws at runtime.
  5. 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.
  6. Stale content after edits. If you use export const dynamic = "force-static", newly added docs require a rebuild. Use revalidate or delete the cache if you want on-demand rebuilds.
  7. Frontmatter typos fail silently. gray-matter returns whatever keys it finds. Validate with zod or a typed assertion to catch missing title or order fields early.

Alternatives

ToolStyleBest For
next-mdx-remoteRSC-friendly, flexibleCustom docs sites in Next.js
ContentlayerType-safe content layerDeprecated — unmaintained
VeliteContentlayer successorType-safe content with validation
FumadocsFull docs frameworkBatteries-included docs on Next.js
NextraDocs framework on Next.jsOpinionated, fast setup
MintlifyHosted SaaSZero-config hosted docs
StarlightAstro-basedDocs 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 &lt;Component&gt;"

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).