Skip to main content
Technology & EngineeringSeo Content307 lines

Mdx

"MDX authoring with Next.js: Markdown + JSX, custom components, frontmatter extraction, @next/mdx, mdx-bundler, contentlayer integration, rehype/remark plugins, and syntax highlighting with Shiki or Prism."

Quick Summary28 lines
MDX lets you embed React components directly inside Markdown, bridging the gap between content authoring and interactive UI. Content lives in `.mdx` files that look like Markdown but support JSX expressions, imports, and component composition. The compilation pipeline — remark (AST transforms on Markdown), rehype (AST transforms on HTML), and the MDX compiler — converts `.mdx` into React components at build time or runtime. This enables rich, interactive documentation and blog posts while keeping the authoring experience simple for writers who think in Markdown.

## Key Points

- Define `mdx-components.tsx` at the project root for App Router — Next.js auto-discovers it and applies it to all MDX files in the app directory.
- Use `rehype-pretty-code` with Shiki for build-time syntax highlighting instead of client-side Prism; it produces zero-JS highlighted code blocks.
- Keep MDX files in a `content/` directory separate from `app/` to decouple content from routing logic.
- Create a component map (`useMDXComponents`) that maps HTML elements to your design system — this gives consistent styling without requiring authors to use custom components.
- Use `gray-matter` to parse frontmatter before passing to the MDX compiler so you can use frontmatter values in page metadata.
- Add `remark-gfm` to support GitHub Flavored Markdown features: tables, strikethrough, task lists, and autolinks.
- Validate frontmatter with Zod schemas to catch missing fields at build time.
- Co-locate images with MDX files and use `next/image` via custom component mapping for automatic optimization.
- **Using `dangerouslySetInnerHTML` for MDX output**: mdx-bundler and @next/mdx already produce React elements. Injecting raw HTML bypasses React's reconciliation and opens XSS vectors.
- **Skipping `rehype-slug`**: Without it, headings have no `id` attributes, breaking anchor links and table-of-contents navigation.
- **Embedding large data in MDX files**: MDX compiles to JavaScript. Large inline JSON bloats the bundle. Fetch data in the page component and pass it as props instead.
- **Using `eval` or `new Function` to run MDX client-side**: Use `mdx-bundler/client` or `@mdx-js/react` which handle sandboxing. Rolling your own evaluation is fragile and insecure.

## Quick Example

```bash
npm install @next/mdx @mdx-js/loader @mdx-js/react
```

```bash
npm install mdx-bundler esbuild
```
skilldb get seo-content-skills/MdxFull skill: 307 lines
Paste into your CLAUDE.md or agent config

MDX — Markdown + JSX

Core Philosophy

MDX lets you embed React components directly inside Markdown, bridging the gap between content authoring and interactive UI. Content lives in .mdx files that look like Markdown but support JSX expressions, imports, and component composition. The compilation pipeline — remark (AST transforms on Markdown), rehype (AST transforms on HTML), and the MDX compiler — converts .mdx into React components at build time or runtime. This enables rich, interactive documentation and blog posts while keeping the authoring experience simple for writers who think in Markdown.

Setup

@next/mdx (Built-in Next.js Integration)

npm install @next/mdx @mdx-js/loader @mdx-js/react
// next.config.mjs
import createMDX from "@next/mdx";

const withMDX = createMDX({
  options: {
    remarkPlugins: [],
    rehypePlugins: [],
  },
});

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ["ts", "tsx", "md", "mdx"],
};

export default withMDX(nextConfig);
// mdx-components.tsx (required at project root for App Router)
import type { MDXComponents } from "mdx/types";
import { Code } from "@/components/code";
import { Callout } from "@/components/callout";

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    h1: ({ children }) => (
      <h1 className="text-4xl font-bold mt-8 mb-4">{children}</h1>
    ),
    pre: ({ children, ...props }) => <Code {...props}>{children}</Code>,
    Callout,
    ...components,
  };
}

mdx-bundler (Runtime Compilation)

npm install mdx-bundler esbuild
// lib/mdx.ts
import { bundleMDX } from "mdx-bundler";
import path from "path";
import fs from "fs/promises";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";

export async function getCompiledMDX(slug: string) {
  const filePath = path.join(process.cwd(), "content", `${slug}.mdx`);
  const source = await fs.readFile(filePath, "utf-8");

  const { code, frontmatter } = await bundleMDX({
    source,
    mdxOptions(options) {
      options.remarkPlugins = [...(options.remarkPlugins ?? []), remarkGfm];
      options.rehypePlugins = [
        ...(options.rehypePlugins ?? []),
        rehypeSlug,
        [rehypeAutolinkHeadings, { behavior: "wrap" }],
      ];
      return options;
    },
  });

  return { code, frontmatter };
}
// app/blog/[slug]/page.tsx
import { getCompiledMDX } from "@/lib/mdx";
import { MDXContent } from "@/components/mdx-content";

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const { code, frontmatter } = await getCompiledMDX(slug);

  return (
    <article className="prose dark:prose-invert max-w-none">
      <h1>{frontmatter.title as string}</h1>
      <MDXContent code={code} />
    </article>
  );
}
// components/mdx-content.tsx
"use client";

import { useMemo } from "react";
import { getMDXComponent } from "mdx-bundler/client";
import { Callout } from "./callout";
import { CodeBlock } from "./code-block";

const mdxComponents = {
  Callout,
  pre: CodeBlock,
};

export function MDXContent({ code }: { code: string }) {
  const Component = useMemo(() => getMDXComponent(code), [code]);
  return <Component components={mdxComponents} />;
}

Key Techniques

Frontmatter Extraction with Gray-Matter

// lib/frontmatter.ts
import matter from "gray-matter";
import fs from "fs/promises";
import path from "path";

export interface PostFrontmatter {
  title: string;
  description: string;
  date: string;
  tags: string[];
  published: boolean;
}

export async function getPostFrontmatter(
  slug: string
): Promise<PostFrontmatter> {
  const filePath = path.join(process.cwd(), "content/posts", `${slug}.mdx`);
  const raw = await fs.readFile(filePath, "utf-8");
  const { data } = matter(raw);

  return {
    title: data.title,
    description: data.description,
    date: data.date,
    tags: data.tags ?? [],
    published: data.published ?? false,
  };
}

Syntax Highlighting with Shiki

npm install shiki rehype-pretty-code
// next.config.mjs
import createMDX from "@next/mdx";
import rehypePrettyCode from "rehype-pretty-code";

const withMDX = createMDX({
  options: {
    rehypePlugins: [
      [
        rehypePrettyCode,
        {
          theme: "github-dark",
          keepBackground: true,
          onVisitLine(node: { children: unknown[] }) {
            if (node.children.length === 0) {
              node.children = [{ type: "text", value: " " }];
            }
          },
          onVisitHighlightedLine(node: { properties: { className: string[] } }) {
            node.properties.className.push("highlighted");
          },
        },
      ],
    ],
  },
});

export default withMDX(nextConfig);

Custom MDX Components

// components/callout.tsx
import React from "react";

interface CalloutProps {
  type?: "info" | "warning" | "error" | "tip";
  title?: string;
  children: React.ReactNode;
}

const styles = {
  info: "bg-blue-50 border-blue-200 text-blue-900 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-100",
  warning: "bg-yellow-50 border-yellow-200 text-yellow-900 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-100",
  error: "bg-red-50 border-red-200 text-red-900 dark:bg-red-950 dark:border-red-800 dark:text-red-100",
  tip: "bg-green-50 border-green-200 text-green-900 dark:bg-green-950 dark:border-green-800 dark:text-green-100",
};

export function Callout({ type = "info", title, children }: CalloutProps) {
  return (
    <div className={`border-l-4 p-4 my-6 rounded-r ${styles[type]}`}>
      {title && <p className="font-semibold mb-1">{title}</p>}
      <div className="text-sm">{children}</div>
    </div>
  );
}

Table of Contents Generation

// lib/toc.ts
import { remark } from "remark";
import { visit } from "unist-util-visit";
import type { Heading } from "mdast";

export interface TocItem {
  id: string;
  text: string;
  depth: number;
}

export async function extractToc(content: string): Promise<TocItem[]> {
  const items: TocItem[] = [];
  const tree = remark().parse(content);

  visit(tree, "heading", (node: Heading) => {
    const text = node.children
      .filter((c) => c.type === "text")
      .map((c) => ("value" in c ? c.value : ""))
      .join("");
    const id = text.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, "");
    items.push({ id, text, depth: node.depth });
  });

  return items;
}

Remark/Rehype Plugin Pipeline

// lib/mdx-plugins.ts
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypePrettyCode from "rehype-pretty-code";

export const remarkPlugins = [remarkGfm, remarkMath];

export const rehypePlugins = [
  rehypeSlug,
  [rehypeAutolinkHeadings, { behavior: "append" }],
  rehypeKatex,
  [rehypePrettyCode, { theme: "one-dark-pro" }],
] as const;

Best Practices

  • Define mdx-components.tsx at the project root for App Router — Next.js auto-discovers it and applies it to all MDX files in the app directory.
  • Use rehype-pretty-code with Shiki for build-time syntax highlighting instead of client-side Prism; it produces zero-JS highlighted code blocks.
  • Keep MDX files in a content/ directory separate from app/ to decouple content from routing logic.
  • Create a component map (useMDXComponents) that maps HTML elements to your design system — this gives consistent styling without requiring authors to use custom components.
  • Use gray-matter to parse frontmatter before passing to the MDX compiler so you can use frontmatter values in page metadata.
  • Add remark-gfm to support GitHub Flavored Markdown features: tables, strikethrough, task lists, and autolinks.
  • Validate frontmatter with Zod schemas to catch missing fields at build time.
  • Co-locate images with MDX files and use next/image via custom component mapping for automatic optimization.

Anti-Patterns

  • Importing heavy client components in MDX without boundaries: Every component imported into MDX ships to the client unless explicitly server-rendered. Wrap interactive components in dynamic imports.
  • Using dangerouslySetInnerHTML for MDX output: mdx-bundler and @next/mdx already produce React elements. Injecting raw HTML bypasses React's reconciliation and opens XSS vectors.
  • Skipping rehype-slug: Without it, headings have no id attributes, breaking anchor links and table-of-contents navigation.
  • Embedding large data in MDX files: MDX compiles to JavaScript. Large inline JSON bloats the bundle. Fetch data in the page component and pass it as props instead.
  • Using eval or new Function to run MDX client-side: Use mdx-bundler/client or @mdx-js/react which handle sandboxing. Rolling your own evaluation is fragile and insecure.
  • Neglecting dark mode in custom components: Always include dark: variants in Tailwind classes for callouts, code blocks, and other styled MDX components.
  • Not memoizing getMDXComponent: The function parses and creates a component on every call. Always wrap it in useMemo keyed on the code string.

Install this skill directly: skilldb add seo-content-skills

Get CLI access →