MDX to render markdown in Next.js

A practical, production-ready guide to rendering Markdown with MDX in Next.js.

Blog Post Image

TL;DR: MDX lets you write Markdown and drop in React components anywhere. In Next.js (App Router), the cleanest, edge‑friendly approach is build‑time compilation via @next/mdx with a small set of remark/rehype plugins. Render MDX as server components, wire up Shiki via rehype-pretty-code for gorgeous code blocks, add slugged/autolinked headings, and keep image handling simple with a custom <Image/> override.


Why MDX for a Next.js blog/docs site?

  • Markdown + React: write content like docs, but sprinkle in live components (callouts, charts, interactive sandboxes).
  • App Router friendly: MDX can be compiled at build‑time and rendered as React Server Components (RSC), perfect for Edge/Cloudflare deployments.
  • Control: choose your own remark/rehype plugins; no CMS or heavy framework required.

The three common approaches (pick one)

  1. Build‑time imports with @next/mdx (recommended)

    • MDX is compiled during the build → fast runtime, edge‑safe, zero serverless cold starts.
    • Works great with static export or SSG pages.
  2. next-mdx-remote (RSC variant) + xdm

    • Good when content comes from a DB or remote source at request time.
    • Slightly more moving parts; less ideal on Edge unless you prebuild.
  3. Contentlayer

    • Batteries included content graph and types. Nice developer experience, but adds a service layer and can fall behind Next.js releases.

If you don’t need runtime compilation, go with (1).


Directory shape

app/
  blog/
    [slug]/page.tsx
  mdx-components.tsx
content/
  blog/
    building-with-mdx/
      index.mdx
      assets/
        diagram.png
lib/
  posts.ts
next.config.mjs

Step 1 — Install the pieces

pnpm add @next/mdx @mdx-js/react remark-gfm rehype-slug rehype-autolink-headings rehype-pretty-code remark-frontmatter remark-mdx-frontmatter

We use remark-mdx-frontmatter so frontmatter becomes named exports you can read directly from the MDX module.


Step 2 — Wire up @next/mdx in next.config.mjs

// next.config.mjs
import createMDX from "@next/mdx";
import remarkGfm from "remark-gfm";
import remarkFrontmatter from "remark-frontmatter";
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypePrettyCode from "rehype-pretty-code";

const withMDX = createMDX({
  options: {
    remarkPlugins: [
      remarkGfm,
      [remarkFrontmatter, { type: "yaml", marker: "-" }],
      remarkMdxFrontmatter,
    ],
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: "wrap" }],
      [
        rehypePrettyCode,
        {
          theme: {
            light: "github-light",
            dark: "github-dark-dimmed",
          },
          keepBackground: false,
          defaultLang: "text",
        },
      ],
    ],
    // Important for frontmatter export names in ESM
    format: "mdx",
  },
});

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ["ts", "tsx", "md", "mdx"],
  experimental: {
    mdxRs: true, // Next’s Rust-based MDX compiler. Disable if you hit a bundler edge-case.
  },
};

export default withMDX(nextConfig);

Heads‑up: If you hit bundler issues (e.g., Turbopack + MDX plugins), toggle experimental.mdxRs or temporarily fall back to Webpack.


Step 3 — Author an MDX post with frontmatter

---
title: "Building with MDX"
description: "Mix Markdown with React components."
date: "2025-08-01"
tags: ["mdx", "nextjs"]
cover: "./assets/diagram.png"
readingTime: 7
---

# Building with MDX

Some intro text.

<MyCallout>MDX can render **real components** like this.</MyCallout>

Note the relative image path in cover and a short readingTime—we’ll read these exports in our page.


Step 4 — Server‑only MDX components mapping

In the App Router, skip context providers; just pass a components map to the compiled MDX module.

// app/mdx-components.tsx (RSC)
import Image from "next/image";
import type { MDXComponents } from "mdx/types";

export function mdxComponents(slug?: string): MDXComponents {
  return {
    h2: (props) => <h2 className="mt-10 scroll-m-20 text-2xl font-semibold" {...props} />,
    h3: (props) => <h3 className="mt-8 scroll-m-20 text-xl font-semibold" {...props} />,
    pre: (props) => (
      <pre className="my-4 rounded-lg border p-4 overflow-x-auto" {...props} />
    ),
    code: (props) => <code className="rounded px-1 py-0.5" {...props} />,

    // Make relative images in MDX work nicely
    img: ({ src = "", alt = "", ...rest }) => {
      const s = String(src);
      const resolved = s