Next.js 16 MDX Guide: @next/mdx, Frontmatter, and Custom Components in App Router (2026)
Set up MDX in Next.js 16 App Router the right way: install @next/mdx, register mdx-components.tsx, add Shiki highlighting with rehype-pretty-code, and know when to switch to next-mdx-remote/rsc for CMS-driven content.
Next.js 16 handles MDX natively through the @next/mdx package. Install the plugin, add mdx to pageExtensions in next.config.ts, and any page.mdx file inside app/ becomes a real React Server Component page with the same layouts, streaming, and metadata behavior as page.tsx. If your MDX lives in a database, a CMS, or on disk outside the routing tree, you render it with next-mdx-remote/rsc instead. This guide walks through both paths, plus custom components, frontmatter, Shiki syntax highlighting, table-of-contents extraction, and the Turbopack and caching pitfalls I’ve personally hit shipping MDX to production.
@next/mdx 16.3 is the official plugin for route-level MDX pages in the App Router and works with React Server Components by default.
Use @next/mdx when MDX files live inside app/. Use next-mdx-remote/rsc when MDX comes from a database, CMS, or non-route filesystem location.
A global mdx-components.tsx at the project root is required for App Router. It lets you override any Markdown element (h1, pre, a) with a React component.
For syntax highlighting, rehype-pretty-code plus Shiki runs at build time and produces zero-runtime, VS Code–quality output that works inside Server Components.
Frontmatter isn’t handled by MDX itself. Either export const metadata from the MDX module, or parse YAML with gray-matter for remote content.
Turbopack now supports MDX plugins in Next.js 16, but Rehype plugins that depend on Node APIs still need the Node.js runtime, not Edge.
What is MDX and why use it with Next.js 16?
MDX is Markdown with JSX. You write prose the same way you would in a README, but any React component you import can be dropped inline: a <Callout>, a chart, an <Image>, an interactive playground. In Next.js 16, MDX compiles to a real React Server Component module. That means it participates in streaming, benefits from the React Compiler’s auto-memoization, and can call server-only APIs like headers() or a Drizzle query without any client bundle cost.
For a platform team, the pull is practical. MDX turns content into typed modules. Import errors become build errors. Renamed components fail loudly instead of silently rendering as plain text. And because the output is a component, the same design-system primitives you use in page.tsx (spacing tokens, typography, dark-mode variables) automatically apply to your blog posts and docs.
So, the two supported modes are ergonomically different. @next/mdx treats an .mdx file as a page or a layout in the App Router. next-mdx-remote/rsc compiles arbitrary MDX strings on the server, which is what you want when posts live in a CMS, a database, or a Git-backed content repo you fetch at request or build time. The rest of this guide covers both.
How do you set up @next/mdx in Next.js 16?
Install the four packages the official docs recommend. As of @next/mdx 16.3.0, the peer-dependency set is stable and matches Next.js 16’s React 19.2 target.
Then wire it into next.config.ts. Add md and mdx to pageExtensions so the App Router treats them as route files, and wrap the config with createMDX:
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
const nextConfig: NextConfig = {
// Files matching these extensions become routable pages.
pageExtensions: ["ts", "tsx", "md", "mdx"],
};
const withMDX = createMDX({
// Only files ending in .md or .mdx are compiled through the MDX loader.
extension: /\.mdx?$/,
options: {
// remarkPlugins run against the Markdown AST.
remarkPlugins: [],
// rehypePlugins run against the HTML AST.
rehypePlugins: [],
},
});
export default withMDX(nextConfig);
Two Next.js 16 details worth calling out. First, the config file is now next.config.ts by default and Turbopack loads it natively. No more .mjs gymnastics just to avoid CommonJS. Second, the old experimental.mdxRs flag has been removed. In Next.js 16, MDX is compiled with the JavaScript pipeline regardless of bundler, which is the only combination that supports the full remark/rehype plugin ecosystem. If you were using mdxRs for speed, the switch to Turbopack usually more than makes up for it. For background on the bundler change, see our Next.js Turbopack guide.
Building route-level MDX pages in the App Router
Once pageExtensions is set, the App Router treats .mdx files identically to .tsx files. A file at app/docs/introduction/page.mdx becomes the route /docs/introduction. It inherits the nearest layout.tsx, gets its own loading.tsx and error.tsx boundaries, and can be statically generated, dynamically rendered, or streamed like any other page.
A minimal MDX page looks like this:
---
# app/docs/introduction/page.mdx
---
export const metadata = {
title: "Introduction",
description: "How to think about our platform.",
};
# Introduction
Welcome to the platform. This paragraph is Markdown, but the button below is a real React component:
<CallToAction href="/signup">Get started</CallToAction>
## Why we built it
The rest of the page is normal Markdown with occasional JSX interruptions.
A few things are happening. export const metadata is picked up by the App Router’s Metadata API the same way it would be from page.tsx, so the article title, Open Graph tags, and canonical URL are all handled centrally. You don’t need <Head>. If you rely on the Metadata API for canonical URLs and JSON-LD, our Next.js SEO guide covers the full surface, and everything there applies to MDX pages unchanged.
The Markdown-level headings (#, ##) become <h1>, <h2>, and so on. Any component you reference must be importable, either declared inline with an import statement at the top of the MDX file, or made globally available through mdx-components.tsx, which is covered a few sections down. Async imports and dynamic segments work: app/docs/[slug]/page.mdx is legal, though usually the more useful pattern is a dynamic route that renders remote MDX, which we’ll get to.
Handling frontmatter and page metadata
Traditional Markdown pipelines lean on YAML frontmatter, a fenced block at the top of the file with title:, date:, and tags: keys. Here’s the surprise: @next/mdx does not parse frontmatter by default. In fact, if you paste a raw YAML block into a page.mdx it will render as a horizontal rule followed by literal title: My post text. Every team runs into this at least once.
You have two clean options. For MDX-as-page files, skip frontmatter entirely and use JavaScript exports:
export const metadata = {
title: "Shipping MDX at scale",
description: "Lessons from moving 800 docs pages to the App Router.",
authors: [{ name: "Mei-Lin Wu" }],
openGraph: {
images: ["/og/mdx-at-scale.png"],
},
};
export const post = {
publishedAt: "2026-08-08",
category: "engineering",
readingMinutes: 12,
};
# Shipping MDX at scale
...
Any named export const in an MDX module is available to the parent module through import { post } from "./page.mdx". That lets you build listing pages by importing the sibling MDX modules and reading their exports at build time. No YAML parser, no runtime scan of a filesystem tree.
The other option, required when MDX comes from a database or filesystem outside the routing tree, is a real frontmatter parser. gray-matter is the canonical choice:
import matter from "gray-matter";
import { readFile } from "node:fs/promises";
// Reads a Markdown file and separates YAML frontmatter from the body.
export async function loadPost(slug: string) {
const raw = await readFile(`content/posts/${slug}.mdx`, "utf8");
const { data, content } = matter(raw);
// `data` is the parsed YAML object; `content` is the MDX source.
return { frontmatter: data as PostFrontmatter, source: content };
}
If you’re fetching posts from a headless CMS with preview URLs, wire the loader through Next.js Draft Mode so editors see unpublished content without breaking cache invalidation for everyone else.
How do you add custom components to MDX in Next.js?
MDX gives you two ways to inject components: local import statements inside the MDX file itself, or a global mapping through mdx-components.tsx. In the App Router, the file mdx-components.tsx at the project root (not inside app/) is required, even if it just exports an empty object. Without it, compilation works but the customization API is silently disabled, which becomes obvious the first time you try to override headings and nothing happens.
// mdx-components.tsx (project root, next to next.config.ts)
import type { MDXComponents } from "mdx/types";
import Link from "next/link";
import Image, { ImageProps } from "next/image";
// This function is called once by @next/mdx for every MDX file.
// The `components` argument holds anything a caller (e.g. next-mdx-remote)
// has already provided. Merge, don’t clobber.
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
// Every <h2> in Markdown becomes an anchored heading.
h2: ({ children, id }) => (
<h2 id={id} className="scroll-mt-20 text-2xl font-semibold">
<a href={`#${id}`} className="no-underline hover:underline">
{children}
</a>
</h2>
),
// Route all Markdown links through next/link for prefetching,
// but only for internal URLs. External links keep <a>.
a: ({ href = "", children, ...rest }) => {
const isInternal = href.startsWith("/") || href.startsWith("#");
return isInternal ? (
<Link href={href} {...rest}>{children}</Link>
) : (
<a href={href} rel="noopener" target="_blank" {...rest}>{children}</a>
);
},
// Swap Markdown images for next/image so we get AVIF and lazy loading.
img: (props) => (
<Image
{...(props as ImageProps)}
width={800}
height={450}
className="rounded-lg"
/>
),
};
}
The mapping is HTML-tag–keyed. Anything the MDX compiler emits as an <h2>, <a>, or <img> passes through your override. You can also register PascalCase names for components you reference directly in MDX: Callout: MyCallout lets authors write <Callout /> without an import at the top of every file.
How do you add syntax highlighting to MDX?
Runtime syntax highlighters like Prism ship a large client bundle and re-parse code on every navigation. For an MDX-heavy docs site, that’s wasted bytes. Honestly, the current best answer is rehype-pretty-code, a Rehype plugin powered by Shiki that runs at build time and emits already-highlighted HTML. It uses VS Code’s TextMate grammars, so highlighting matches what your editor shows for TypeScript, TSX, Rust, and about 200 other languages.
npm install rehype-pretty-code shiki --save-exact
Register it in next.config.ts:
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
import rehypePrettyCode, { type Options } from "rehype-pretty-code";
const prettyCodeOptions: Options = {
// Use two VS Code themes so we can support light and dark mode with CSS vars.
theme: {
light: "github-light",
dark: "github-dark-dimmed",
},
// Let our own CSS control the background. The plugin injects data attributes
// that CSS can key off of, e.g. data-line, data-highlighted-line.
keepBackground: false,
};
const withMDX = createMDX({
extension: /\.mdx?$/,
options: {
remarkPlugins: [],
rehypePlugins: [[rehypePrettyCode, prettyCodeOptions]],
},
});
const nextConfig: NextConfig = {
pageExtensions: ["ts", "tsx", "md", "mdx"],
};
export default withMDX(nextConfig);
The plugin annotates each token with inline styles for both themes. To make the switch work, add a small stylesheet that reveals one set of styles at a time:
/* app/globals.css */
pre { overflow-x: auto; padding: 1rem; border-radius: 0.5rem; }
pre [data-line] { padding: 0 1rem; }
/* Show light-theme colors by default, swap to dark under the dark class. */
html.dark pre code span { color: var(--shiki-dark) !important; }
html:not(.dark) pre code span { color: var(--shiki-light) !important; }
Because Shiki runs at build time, your code blocks are static HTML with zero client-side JavaScript for highlighting. On a 500-page docs site I migrated last quarter, this cut the shared JS chunk by about 84 KB gzipped and got rid of the flash-of-unhighlighted-code that runtime libraries produce. For interactive features like line highlighting or copy-code buttons, you can add a small client component that reads data-line attributes off the pre-rendered HTML.
What is the difference between @next/mdx and next-mdx-remote?
Short version: use @next/mdx when MDX files live inside app/ as pages. Use next-mdx-remote/rsc when MDX is content data (fetched from a database, CMS, S3 bucket, or Git repo) and rendered inside an otherwise normal page.tsx. Mixing those mental models is where most of the confusion comes from.
Concern
@next/mdx
next-mdx-remote/rsc
MDX source
Files on disk under app/
Any string (DB, CMS, filesystem, Git)
Compile time
Build time (or first request)
Request time on the server
Layouts / metadata
Native App Router support
Wrap manually in page.tsx
MDX-in-MDX imports
Yes
No
MDXProvider context
Via mdx-components.tsx
Pass components prop directly
Client component usage
Full support
Full support
Best for
Docs sites, static content shipped with the app
Blogs, marketing pages, editor-driven content
The remote path looks like this in practice:
// app/blog/[slug]/page.tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import { notFound } from "next/navigation";
import { loadPost } from "@/lib/posts";
import { mdxComponents } from "@/mdx-components";
// Next.js 16: params is a Promise, so await it.
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await loadPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.frontmatter.title}</h1>
<MDXRemote
source={post.source}
components={mdxComponents}
options={{
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [],
},
parseFrontmatter: true,
}}
/>
</article>
);
}
Because MDXRemote is an async server component, you can safely call await db.query(...) or hit an external CMS without introducing a client-side waterfall. One behavior that trips people up: MDXProvider from @mdx-js/react doesn’t work here, because React Server Components don’t support React Context. Pass the components map as a prop instead, as shown above. For the params Promise migration itself, if you’re moving an older app, our params-is-now-a-Promise migration fix walks through the codemod and its gaps.
How do you generate a table of contents from MDX?
There are two solid patterns, depending on when you know the heading list. For route-level page.mdx files, extract headings at build time. For remote MDX, extract them during compilation.
The build-time approach uses remark-mdx-toc or a small custom Remark plugin that walks the AST and collects heading nodes. You then export const toc from the MDX module and read it from a sibling layout:
// A minimal Remark plugin that adds an exported `toc` array.
// Place at lib/remark/collect-toc.ts and add to remarkPlugins.
import { visit } from "unist-util-visit";
import { toString } from "mdast-util-to-string";
import { valueToEstree } from "estree-util-value-to-estree";
import type { Root } from "mdast";
export function collectToc() {
return (tree: Root) => {
const items: { depth: number; text: string; id: string }[] = [];
visit(tree, "heading", (node) => {
const text = toString(node);
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
items.push({ depth: node.depth, text, id });
});
// Inject `export const toc = [...]` at the top of the MDX module.
tree.children.unshift({
type: "mdxjsEsm",
value: "",
data: {
estree: {
type: "Program",
sourceType: "module",
body: [
{
type: "ExportNamedDeclaration",
specifiers: [],
declaration: {
type: "VariableDeclaration",
kind: "const",
declarations: [{
type: "VariableDeclarator",
id: { type: "Identifier", name: "toc" },
init: valueToEstree(items),
}],
},
},
],
} as any,
},
} as any);
};
}
Now any page.mdx exposes its outline: import { toc } from "./page.mdx" and render a sidebar from it. Combine with rehype-slug so every heading gets a stable id and the anchor links line up. For remote MDX, run the same visitor during your loadPost function and return { frontmatter, source, toc } together. One server-side pass, cached with the rest of the post payload.
Turbopack, RSC caching, and Next.js 16 gotchas
Three production issues eat time. First: Turbopack. As of Next.js 16, Turbopack is the default bundler and it processes MDX through the same JavaScript pipeline as webpack, which means all your remark/rehype plugins work as-is. The catch is that plugins depending on Node-only modules (fs, path, native .node binaries) still need the Node.js runtime, not Edge. If you deploy to Vercel Edge and see a page silently return no highlighted code, your Rehype plugin got tree-shaken because it couldn’t load. Set export const runtime = "nodejs" on the offending route and confirm.
Second: caching. The use cache directive introduced in Next.js 15 works on functions that return MDX content, but the compiled output is a React tree, not a serializable value. Cache the source string and frontmatter, not the rendered <MDXRemote />. If you cache the render, you’ll get an opaque serialization error the first time a client component appears inside a post (I lost an afternoon to this one). For a deeper look at when things fail to invalidate, see our writeup on the cache-not-revalidating server action fix.
Third: hydration mismatches from time-sensitive content. If your MDX includes new Date().toLocaleString() in an {expression}, the server render and client render will disagree. Wrap dynamic content in a client component, or compute it inside a Server Component and pass the string down. Same rule as everywhere else in the App Router, but MDX makes it easy to forget because the markup looks like Markdown.
Frequently Asked Questions
Can you use React Server Components with MDX in Next.js 16?
Yes. Both @next/mdx and next-mdx-remote/rsc render MDX as Server Components by default. The MDX module can call server-only APIs, but any client component embedded in the MDX must have the "use client" directive at the top of its own file.
Do I need mdx-components.tsx at the project root?
Yes, for the App Router. Even if it only exports an empty object, the file must exist so @next/mdx can call useMDXComponents. Place it next to next.config.ts, not inside app/. Missing this file silently disables all global component overrides.
Is Contentlayer still maintained in 2026?
No. Contentlayer stopped receiving updates in early 2024 and doesn’t support the App Router well. The community has largely migrated to next-mdx-remote/rsc, Velite, or Fumadocs for docs sites. For simple use cases, plain @next/mdx plus a small loader is enough.
How do I import an MDX file inside another MDX file?
With @next/mdx, use a normal ES import at the top of the file: import Intro from "./intro.mdx", then render <Intro />. This doesn’t work with next-mdx-remote/rsc because the compiler runs on a string and has no module graph to resolve against.
Why is my MDX code block not highlighted after adding rehype-pretty-code?
The most common cause is missing CSS for Shiki’s dual-theme output. Add rules that show --shiki-light or --shiki-dark based on your color-scheme class. If the HTML source has no <span> tokens at all, the plugin didn’t run, so check that rehypePlugins is passed to createMDX, not to nextConfig.
Learn how to install Better Auth in Next.js 16, wire up Drizzle or Prisma, add email/password plus Google and GitHub social login, and lock down routes with proxy.ts and Server Action session checks.
Prefetch on the server, hydrate on the client. A 2026 guide to using TanStack Query v5 with the Next.js 16 App Router: HydrationBoundary, streaming, Server Actions, and the common hydration errors that break RSC setups.
Real effort estimates and a phase-by-phase playbook for migrating from Gatsby to Next.js 16 App Router: GraphQL to Server Components, plugin mapping, dynamic routes, redirects, and deployment.