MDX content in Next.js
Write pages in Markdown with React components inside: @next/mdx, mdx-components.tsx, frontmatter, build-time code highlighting and MDX from a CMS.
- 01Set up MDXInstall @next/mdx, list mdx in pageExtensions, add mdx-components.tsx, and choose between a page.mdx and an imported file.
- 02Add data and pluginsGive posts frontmatter, add remark and rehype plugins by name for Turbopack, and highlight code in the build.
- 03Use MDX from a CMS safelyRender remote MDX only from people who deploy your code, know that it runs code, and use plain Markdown for every other text.
A journal post written in Markdown
The tea shop wants a journal: short posts about new teas. Written as React components, every paragraph needs a <p> and every bold word a <strong>. Markdown is a way to write formatted text as plain text, with a few marks:
- A line that starts with
#is a heading. - Two stars around words,
**like this**, make them bold. Underscores,_like this_, make them italic. - A line that starts with
-is an item in a list.
MDX is Markdown that can also hold JSX: you can put a React component in the middle of the text. Next.js reads it with a package called @next/mdx. Here is the shop's first post, in a real Next.js 16.4.3 project. It was built with next build, run with next start, and asked for /journal/first-flush. Click a file in the tree to read it.
app/journal/first-flush/page.mdx# The first flush Our **Darjeeling** arrived this week. It tastes of _flowers_ and fresh grass. - Brew it at 80 °C.- Steep it for 3 minutes.GET /journal/first-flush200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<!--&-->
<h1>The first flush</h1>
<p>Our <strong>Darjeeling</strong> arrived this week. It tastes of <em>flowers</em> and fresh grass.</p>
<ul>
<li>Brew it at 80 °C.</li>
<li>Steep it for 3 minutes.</li>
</ul>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>app/journal/first-flush/page.mdx is a page, like a page.tsx, so its address is /journal/first-flush. Line 1 is a heading. Line 3 has a bold **Darjeeling** and an italic _flowers_. Lines 5 and 6 are a list. The Network tab shows the HTML that arrived: an <h1>, a <strong>, an <em>, and a <ul> with two <li>. In the Terminal tab, ○ /journal/first-flush means the page was prerendered as static content: the Markdown became HTML during the build, so no visitor waits for it.
@next/mdx turns page.mdx into a React component. Next.js renders it like any other page, so the HTML holds an <h1>, a <strong> and an <em>.A writer sends a draft to a print shop. The draft uses a few simple marks: a line that starts with a hash is a headline, and stars around a word mean bold. The print shop turns each mark into real type before anything is printed, following its style guide. The writer can also write "glue today's price tag here", and the shop puts a real tag in that spot.
- In real life: A draft with simple marks, like stars around a word that should be bold
- In Next.js: A post in Markdown:
**Darjeeling** - In real life: The print shop, which sets the marks into real type before printing
- In Next.js:
@next/mdx, which compiles the file during the build - In real life: A note in the draft: "glue today's price tag here"
- In Next.js: A component inside MDX, like
<Price /> - In real life: The shop's style guide: how each kind of mark should look
- In Next.js:
mdx-components.tsx
MDX is Markdown that can hold React components. With @next/mdx, Next.js compiles each .mdx file into a React component during the build. A page.mdx is a page; any other .mdx file is a component you import. mdx-components.tsx decides which component each Markdown element becomes.
Setting up @next/mdx
A new Next.js app doesn't read .mdx files. Three steps turn them on. First, install four packages:
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
@next/mdxis Next.js's MDX plugin. It tells the bundler to send every.mdxfile through the MDX compiler. (A bundler is the tool that reads your files and turns them into the JavaScript the server and the browser load. In Next.js 16 it's Turbopack.)@mdx-js/loaderis the MDX compiler, packaged so a bundler can call it on each file.@mdx-js/reactis MDX's helper for React. The App Router uses yourmdx-components.tsxinstead, but the docs install it.@types/mdxhas the TypeScript types that let youimport Post from "./post.mdx".
Second, wrap the settings in next.config.ts with the plugin. This is the same project, open on that file:
next.config.tsimport type { NextConfig } from "next";import createMDX from "@next/mdx"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, pageExtensions: ["ts", "tsx", "mdx"],}; const withMDX = createMDX({}); export default withMDX(nextConfig);$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ○ /journal/first-flush ○ (Static) prerendered as static contentLine 2 imports createMDX. Line 10 makes the plugin, and line 12 wraps the settings with it. Line 7, pageExtensions, lists the file endings that can be pages and routes. Without it, Next.js uses tsx, ts, jsx and js. Setting it replaces that list, so line 7 keeps ts and tsx and adds mdx. (@next/mdx compiles only .mdx files; the docs show its extension option for .md files too.)
Third, add mdx-components.tsx next to the app folder: the file that says which component renders each Markdown element. Leave out a step below, and read what the build printed:
next.config.tsimport type { NextConfig } from "next";import createMDX from "@next/mdx"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, pageExtensions: ["ts", "tsx", "mdx"],}; const withMDX = createMDX({}); export default withMDX(nextConfig);$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ○ /journal/first-flush ○ (Static) prerendered as static contentLine 7 lists mdx in pageExtensions, and mdx-components.tsx is there. The build lists ○ /journal/first-flush, and the page answers 200 with the post.
Without pageExtensions, the build passes, but the route table has no /journal/first-flush, only a /404 page that Next.js made on its own. The visit answers 404. The .mdx file would still compile if a page imported it. It just isn't a page.
With Turbopack, @next/mdx adds a rule for files that end in .mdx: run them through the MDX loader, then treat the result as a .tsx file (lines 51 to 83 of next-mdx/index.js). So after the compiler, a post is bundled like any component you write.
mdx-components.tsx, required and yours
In the App Router, mdx-components.tsx at the project's root (or in src) is required. Pick No mdx-components.tsx in the playground above: the build stops with TypeError: c.default.createContext is not a function, in page.mdx.tsx, the post after the compiler. The compiled post asks for a function called useMDXComponents from a module Next.js looks for in this order:
mdx-componentsin the project's root,mdx-componentsinsrc,- the
@mdx-js/reactpackage, - an empty stand-in inside
@next/mdx.
Next.js lists them at lines 1112 to 1121 of next_import_map.rs. With no file of yours, it finds @mdx-js/react, which calls React's createContext as it loads. A page is a Server Component, and React context doesn't exist for Server Components, so the page can't load. The fix is the file, even with nothing in it:
import type { MDXComponents } from "mdx/types";
const components: MDXComponents = {};
export function useMDXComponents(): MDXComponents {
return components;
}
Line 1 imports the type of the map. Line 3 is the map, empty. Lines 5 to 7 are the function the compiled posts call: its name, useMDXComponents, is fixed, and it returns the map.
Your components for Markdown's elements
The map's keys are element names, like h1, a or img, and each value is the component that renders that element in every post. The shop's first post now links to the journal with [all posts](/journal), Markdown for a link. An empty map makes that a plain <a>. Mapping a to Next.js's Link makes it a client-side navigation (Linking and navigating). The Steps tab is real Chrome, opening the post and clicking all posts:
mdx-components.tsximport type { MDXComponents } from "mdx/types"; const components: MDXComponents = {}; export function useMDXComponents(): MDXComponents { return components;}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Our Darjeeling arrived this week. It tastes of flowers and fresh grass.
Read all posts.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
GET/journal/first-flush200a whole page
Line 3 maps nothing, so the link is a plain <a>. In Steps, the click loaded a whole new page: a document request for /journal.
With the empty map, the click's requests start with a document request for /journal: the browser threw the page away and loaded a new one. With a mapped to Link on line 5, there's no document request. The router fetched /journal's RSC payload (React's description of what the page's Server Components rendered) with the header rsc: 1, and swapped the page in place.
mdx-components.tsx maps a to Next.js's Link. It doesn't list h1, so the heading stays a plain <h1>.Line 5's href = "/" gives href a default, because a Markdown element's props are optional in the types. Every post uses the same map, so one line changes every link in the journal.
MDX pages, and MDX files you import
A page.mdx is a whole page, with its own folder. With many posts, that's many folders. The other way: keep the posts in a content folder, outside app, and let one dynamic page import the right one. An .mdx file you import is a React component, like any other.
app/journal/[slug]/page.tsximport { notFound } from "next/navigation"; const slugs = ["first-flush", "tea-timer"]; export function generateStaticParams() { return slugs.map((slug) => ({ slug }));} export async function generateMetadata({ params }: PageProps<"/journal/[slug]">) { const { slug } = await params; if (!slugs.includes(slug)) return {}; const { metadata } = await import(`@/content/${slug}.mdx`); return metadata;} export default async function Post({ params }: PageProps<"/journal/[slug]">) { const { slug } = await params; if (!slugs.includes(slug)) notFound(); const { default: Content } = await import(`@/content/${slug}.mdx`); return <Content />;}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (5/5) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ /journal/[slug] ├ ◐ /journal/[slug] ├ ○ /journal/first-flush └ ○ /journal/tea-timer ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentLine 3 lists the posts. Lines 5 to 7, generateStaticParams, give Next.js both slugs, so the Terminal tab shows ○ /journal/first-flush and ○ /journal/tea-timer under ◐ /journal/[slug]: both posts were prerendered. Line 19 imports the post with a dynamic import, and line 20 renders it. The address in line 19 is a template literal, so the bundler includes every .mdx file in content that it could match. Line 18 calls notFound() for a slug that isn't in the list. (The docs' version of this page sets dynamicParams = false instead, which a project with Cache Components on can't use.)
Each post file starts with export const metadata = { title: "…" }. Next.js reads metadata from page and layout files only, so for an imported file it's just a value. Lines 12 and 13, in generateMetadata, import the same file and return its metadata. The Network tab shows <title>The first flush</title> and <title>A tea timer</title>.
page.mdx in app is a route of its own. A file in content has no address: the page in [slug] imports it and renders it at /journal/tea-timer.Each row compares the two kinds of MDX file:
| Question | A page.mdx in app | A file in content/ |
|---|---|---|
| Does it have an address? | Yes. Its folder in app is its address, like any page. | No. A page imports it and renders it. |
Does its metadata export set the title? | Yes. Next.js reads it, as from a page.tsx. | No. The page reads it in generateMetadata and returns it. |
| How many posts does one page file serve? | One. | Any number, with one dynamic page and generateStaticParams. |
Frontmatter: an export, or YAML with plugins
Facts about a post, like its title and its author, are called its frontmatter. MDX can already export values: line 1 of the linked post above is export const metadata = { title: "The first flush" }, and in a page.mdx, Next.js uses it as the page's metadata. Open the links playground's Browser tab: the tab's title is The first flush.
Many writing tools and CMSs write frontmatter another way: a block of YAML (a plain-text format of key: value lines) between two --- lines at the top. @next/mdx doesn't read it. Two plugins do:
remark-frontmattertells the Markdown parser that the block is frontmatter, not text.remark-mdx-frontmatterturns that block into an export namedfrontmatter.
A remark plugin changes the Markdown before it becomes HTML; a rehype plugin changes the HTML. Step through the playground: the posts with YAML and no plugins, then with both plugins, then without the type TypeScript needs.
content/first-flush.mdx---title: The first flushauthor: Asha--- # The first flush Our **Darjeeling** arrived this week. It tastes of _flowers_ and fresh grass.Our Darjeeling arrived this week. It tastes of flowers and fresh grass.
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
No plugins: Markdown read the first --- as a horizontal line, and the YAML with the closing --- as a heading: title: The first flush author: Asha.
With no plugins, the page shows the YAML: Markdown read the first --- as a horizontal line (<hr>), and the two lines after it, with the closing ---, as a heading (<h2>). With both plugins, the block is gone from the page, and the journal page lists The first flush by Asha and A tea timer by Ben.
In next.config.ts, line 12 names the plugins as strings. Next.js 16 builds with Turbopack, which is written in Rust, and it takes a loader's options as plain data: names and simple values, never functions. @next/mdx's loader then imports each plugin by its name from your project (lines 8 to 26 of mdx-js-loader.js). Here is the journal page that reads the frontmatter:
import Link from "next/link";
import { frontmatter as firstFlush } from "@/content/first-flush.mdx";
import { frontmatter as teaTimer } from "@/content/tea-timer.mdx";
const posts = [
{ slug: "first-flush", ...firstFlush },
{ slug: "tea-timer", ...teaTimer },
];
export default function Journal() {
return (
<main>
<h1>Tea journal</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/journal/${post.slug}`}>{post.title}</Link> by {post.author}
</li>
))}
</ul>
</main>
);
}
Lines 2 and 3 import each post's frontmatter with a new name. Lines 5 to 8 put them in a list with their slugs, and line 17 shows each title as a link, then the author. TypeScript only knows what @types/mdx says: an .mdx file has a default export. Without more, the build stops with Module '"*.mdx"' has no exported member 'frontmatter'. A small declaration file adds the named export:
declare module "*.mdx" {
export const frontmatter: { title: string; author: string };
}
Line 1 adds to the type of every module whose name ends in .mdx, and line 2 says each one exports a frontmatter with a title and an author. TypeScript trusts it: keep every post's YAML in that shape. Each row is one way to write a post's facts:
| Written as | What it needs | How you read it |
|---|---|---|
export const metadata = { … } | Nothing. MDX allows exports. | import { metadata } from "…mdx", or as a page's metadata in a page.mdx. |
A YAML block between --- lines | remark-frontmatter and remark-mdx-frontmatter, named in next.config.ts. | import { frontmatter } from "…mdx", after you declare its type. |
Server and Client Components inside MDX
The first post now shows today's price and a Like button. Both are components, imported at the top of the post, after the frontmatter:
content/first-flush.mdx---title: The first flushauthor: Asha--- import { Price } from "@/components/price";import { LikeButton } from "@/components/like-button"; # The first flush Our **Darjeeling** arrived this week. It tastes of _flowers_ and fresh grass.Today a box costs <Price tea="darjeeling" />. <LikeButton />Each step was taken in real Chrome against next start. Pick one to see the page after it.
Our Darjeeling arrived this week. It tastes of flowers and fresh grass. Today a box costs $6.
- Focus
- the button “Like (1)”
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
Lines 6 and 7 import the components. Line 12 uses Price, from components/price.tsx: an async Server Component that waits for getTea, a cached function in lib/teas.ts ('use cache'). Line 14 uses LikeButton, from components/like-button.tsx, a Client Component: its file starts with "use client", and it keeps the count in state.
The post compiles into a React component. Here it's a Server Component because a Server Component, the page, imports it. Imported from a "use client" file, the same post would be client code, sent to the browser with its text. It renders on the server, like the page, so Price ran during the build: the Network tab's HTML already says Today a box costs $6. The recording also searched every script file the page loads. It found Like (, from the button's code, and not fresh grass, from the post's text. The text is in the HTML, and in the RSC payload inside it, but in no script file. In the Steps tab, Chrome clicked Like (0), and it became Like (1) with no request at all: the button runs in the browser.
A post follows the rules of the components it holds. Rendered as a Server Component, it can't call useState itself, and a component that needs state or a click goes in a "use client" file, like LikeButton.
Syntax highlighting in the build
The tea timer post shows a little code, in a code block: lines between two rows of three backticks, with the language after the first row. With no plugin, MDX makes it a plain <pre><code>. To color it, the journal uses rehype-pretty-code, a rehype plugin built on Shiki, a highlighter that reads code with the same grammars VS Code uses and colors each word from a theme. Compare the three versions:
content/tea-timer.mdx---title: A tea timerauthor: Ben--- # A tea timer Three minutes for black tea, two for green: ```jsconst minutes = 3;setTimeout(() => console.log("Your tea is ready"), minutes * 60_000);```GET /journal/tea-timer200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 17 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<script src="/_next/static/chunks/2wdwzyp7z4kso.js" async=""></script>
<title>A tea timer</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<!--&-->
<h1>A tea timer</h1>
<p>Three minutes for black tea, two for green:</p>
<pre><code class="language-js">const minutes = 3;
setTimeout(() => console.log("Your tea is ready"), minutes * 60_000);
</code></pre>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>No rehype plugin: the code block is a plain <pre><code class="language-js">, with no colors.
Line 13 of next.config.ts names the plugin with its options: ["rehype-pretty-code", { theme: "github-light" }]. The options are plain values, so Turbopack can take them. In the Network tab, each word of the code is a <span> with a style that sets its color, like <span style="color:#D73A49">const</span>. Shiki did that once, during the build.
None of it reaches the browser. In this recording, the page with colors and the page without them load the same eight script files, with the same names (the Network tab lists them at the end of the HTML), and none of those files contains github-light or Your tea is ready. A highlighter that ran in the browser would send its code and its grammars to every visitor, and the code would flash uncolored first.
The third version imports the plugins and passes the functions themselves, as the docs' setup for webpack does. Turbopack stops before it compiles anything: does not have serializable options. (The recording shows the project's folder as /my-app.) With Turbopack, name the plugins as strings.
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
import remarkFrontmatter from "remark-frontmatter";
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
import rehypePrettyCode from "rehype-pretty-code";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
pageExtensions: ["ts", "tsx", "mdx"],
};
const withMDX = createMDX({
options: {
remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
rehypePlugins: [[rehypePrettyCode, { theme: "github-light" }]],
},
});
export default withMDX(nextConfig);
Lines 3 to 5 import the plugins, and lines 15 and 16 pass them. Compare lines 12 and 13 of the working version:
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
pageExtensions: ["ts", "tsx", "mdx"],
};
const withMDX = createMDX({
options: {
remarkPlugins: ["remark-frontmatter", "remark-mdx-frontmatter"],
rehypePlugins: [["rehype-pretty-code", { theme: "github-light" }]],
},
});
export default withMDX(nextConfig);
MDX from a CMS runs code
Some sites keep MDX in a CMS, a content management system: a website where people write and publish without a deploy. In the tea shop, only its developers publish news this way, and each post gets the same review as their code. The post arrives as MDX text when a page asks for it. @next/mdx only compiles files in the project, so remote MDX needs a compiler that runs while the page renders. next-mdx-remote was the usual choice; its repository is now archived and no longer supported (version 6.0.0 is its last). next-mdx-remote-client (2.1.12), a maintained fork, has an MDXRemote Server Component. This project has no @next/mdx at all; it isn't needed for remote MDX.
Our autumn teas arrive on **Monday**. A box of Darjeeling costs <Price tea="darjeeling" />.
Brew it at {2 * 40} °C.
app/news/[slug]/page.tsximport { MDXRemote } from "next-mdx-remote-client/rsc";import { notFound } from "next/navigation";import { Price } from "@/components/price";import { getPost, getSlugs } from "@/lib/cms"; export async function generateStaticParams() { const slugs = await getSlugs(); return slugs.map((slug) => ({ slug }));} export default async function NewsPost({ params }: PageProps<"/news/[slug]">) { const { slug } = await params; const post = await getPost(slug); if (!post) notFound(); return ( <article> <h1>{post.title}</h1> <MDXRemote source={post.body} components={{ Price }} /> </article> );}Our autumn teas arrive on Monday. A box of Darjeeling costs $6.
Brew it at 80 °C.
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Line 13 gets the post from lib/cms.ts, whose getPost is cached with "use cache". Line 18 hands its body to MDXRemote, with Price as a component the text may use. The CMS is a stand-in the recorder started; it was asked for the list and for the post, both during the build. The Browser tab shows Brew it at 80 °C.: {2 * 40} on line 3 of the body is an expression, a piece of JavaScript, and it ran on the server.
That's the danger. MDX is code. MDXRemote compiles the text into JavaScript and runs it on your server, with the same rights as your own code. Suppose someone takes over a developer's CMS account and adds one line:
Our autumn teas arrive on **Monday**. A box of Darjeeling costs <Price tea="darjeeling" />.
Brew it at {2 * 40} °C.
Thanks for reading! {process.env.SHOP_DB_PASSWORD}
app/news/[slug]/page.tsximport { MDXRemote } from "next-mdx-remote-client/rsc";import { notFound } from "next/navigation";import { Price } from "@/components/price";import { getPost, getSlugs } from "@/lib/cms"; export async function generateStaticParams() { const slugs = await getSlugs(); return slugs.map((slug) => ({ slug }));} export default async function NewsPost({ params }: PageProps<"/news/[slug]">) { const { slug } = await params; const post = await getPost(slug); if (!post) notFound(); return ( <article> <h1>{post.title}</h1> <MDXRemote source={post.body} components={{ Price }} /> </article> );}Our autumn teas arrive on Monday. A box of Darjeeling costs $6.
Brew it at 80 °C.
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
{2 * 40} ran on the server, so the HTML says Brew it at 80 °C., and <Price> rendered $6.
Pick After someone edited it: the page ends with Thanks for reading! sencha-7731. Line 5 of the body read SHOP_DB_PASSWORD, a server environment variable, and the build wrote it into the HTML for every visitor. An expression could just as well read files or call other services.
MDXRemote compiles the CMS's text and runs it on your server, where process.env holds your secrets. Whatever the expression gives goes into the HTML.Two rules follow:
- Remote MDX only from people already trusted to deploy production code, with the same review their code gets. Not editors, not the support team: if MDX runs from their accounts, a stolen CMS password becomes code running on your server.
- Every other text from a CMS, plain Markdown: news by editors, help articles by the support team, anything from outside the company. Pick The edited post as Markdown: line 17 passes
{ mdxOptions: { format: "md" } }, and the page shows{process.env.SHOP_DB_PASSWORD}as text. No expression runs, and no component either: the price is gone.
import { MDXRemote } from "next-mdx-remote-client/rsc";
import { notFound } from "next/navigation";
import { getPost, getSlugs } from "@/lib/cms";
export async function generateStaticParams() {
const slugs = await getSlugs();
return slugs.map((slug) => ({ slug }));
}
export default async function NewsPost({ params }: PageProps<"/news/[slug]">) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<MDXRemote source={post.body} options={{ mdxOptions: { format: "md" } }} />
</article>
);
}
next-mdx-remote-client 2.1.12 also has disableImports and disableExports, both off unless you turn them on. They only remove import and export lines; expressions like {process.env.SHOP_DB_PASSWORD} still run. For text that isn't reviewed like code, format: "md" is the line to draw.
MDXRemote runs on the server, so a Content Security Policy never sees it, and your policy doesn't need 'unsafe-eval' for it. Setups that compile MDX on the server and run it in the browser (next-mdx-remote's older pages-style API) do need 'unsafe-eval', because the browser builds the post's code from a string. Either way, the policy doesn't make untrusted MDX safe.
Because getPost is cached, an edit in the CMS shows up only when the cached copy is refreshed. Tag it, and refresh it from a webhook when a post is published (Revalidating data).
Where should this text live?
Every card is text the tea shop shows. Sort each one by where it should come from and how it should be compiled:
- The shop's About page, written by its developers
- Journal posts the developers review before they go live
- A changelog with code blocks, kept in the repository
- News the shop's developers publish from the CMS, reviewed like code, with a
<Price />in them - Help articles the support team edits in the CMS every week
- Customer reviews typed into a form on the site
- Posts from guest writers who get a CMS account the same day they ask
- Comments under a journal post
Sort each kind of text by who writes it and whether anything in it may run.
The pattern: text that changes with the code goes in .mdx files; MDX from a CMS only when the people who write it already deploy your code and their posts get the same review; everything else as plain Markdown, where nothing runs.
Common mix-ups
- "MDX is turned into HTML in the browser." No.
@next/mdxcompiles it in the build, and the recorded post was prerendered as static content. Its text was in the HTML and in no script file. - "mdx-components.tsx is optional." No. In the App Router the build stopped without it, even though the file can be an empty map.
- "pageExtensions adds mdx to the list." No. It replaces the list. Keep
tsandtsxin it, or your.tsxpages stop being pages. - "YAML frontmatter just works." No. Without
remark-frontmatterandremark-mdx-frontmatter, it showed on the page as a line and a heading. - "Plugins go in next.config.ts like any import." With Turbopack, no. Name them as strings, with plain options; the functions themselves stopped the build.
- "Text from a CMS is only text." Not as MDX. It's code that runs on your server, and it printed a secret into the page.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
Which HTML element does **Darjeeling** on line 3 become?
# The first flush
Our **Darjeeling** arrived this week. It tastes of _flowers_ and fresh grass.
- Brew it at 80 °C.
- Steep it for 3 minutes.
<strong>. Two stars mark bold text, and MDX turns it into a <strong> element. The underscores around flowers became an <em>, and the two - lines a <ul> with two <li>.
app/journal/first-flush/page.mdx exists, mdx-components.tsx exists, and the build passes. But /journal/first-flush answers 404. Which setting is missing from this next.config.ts?
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
const withMDX = createMDX({});
export default withMDX(nextConfig);
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
pageExtensions: ["ts", "tsx", "mdx"],
};
const withMDX = createMDX({});
export default withMDX(nextConfig);
pageExtensions. Line 7 lists ts, tsx and mdx: the list replaces the default, so it keeps the TypeScript endings and adds mdx. The recording then lists ○ /journal/first-flush.
The journal writes tips as Markdown quotes: > Tip: use water that has just stopped boiling. Make every quote render as <aside className="tip">. Which element name do you add to the map?
import type { MDXComponents } from "mdx/types";
const components: MDXComponents = {};
export function useMDXComponents(): MDXComponents {
return components;
}
import type { MDXComponents } from "mdx/types";
import Link from "next/link";
const components: MDXComponents = {
a: ({ href = "/", children }) => <Link href={href}>{children}</Link>,
blockquote: ({ children }) => <aside className="tip">{children}</aside>,
};
export function useMDXComponents(): MDXComponents {
return components;
}
blockquote. Line 6 maps it to an <aside className="tip">. The recorded post's > Tip: use water that has just stopped boiling. came out as <aside class="tip">, and every quote in every post does the same.
The plugins are set up, but the build stops at lines 2 and 3 of this page with Module '"*.mdx"' has no exported member 'frontmatter'. What file do you add?
import Link from "next/link";
import { frontmatter as firstFlush } from "@/content/first-flush.mdx";
import { frontmatter as teaTimer } from "@/content/tea-timer.mdx";
const posts = [
{ slug: "first-flush", ...firstFlush },
{ slug: "tea-timer", ...teaTimer },
];
export default function Journal() {
return (
<main>
<h1>Tea journal</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/journal/${post.slug}`}>{post.title}</Link> by {post.author}
</li>
))}
</ul>
</main>
);
}
declare module "*.mdx" {
export const frontmatter: { title: string; author: string };
}
A declaration file, mdx.d.ts, that adds export const frontmatter: { title: string; author: string } to every *.mdx module. With it, the recorded build passes and the journal lists both posts.
The shop wants to show customer reviews, typed into a form, with Markdown's bold and links. A developer copies this news page. Which option keeps the reviews' text from running?
import { MDXRemote } from "next-mdx-remote-client/rsc";
import { notFound } from "next/navigation";
import { Price } from "@/components/price";
import { getPost, getSlugs } from "@/lib/cms";
export async function generateStaticParams() {
const slugs = await getSlugs();
return slugs.map((slug) => ({ slug }));
}
export default async function NewsPost({ params }: PageProps<"/news/[slug]">) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<MDXRemote source={post.body} components={{ Price }} />
</article>
);
}
import { MDXRemote } from "next-mdx-remote-client/rsc";
import { notFound } from "next/navigation";
import { getPost, getSlugs } from "@/lib/cms";
export async function generateStaticParams() {
const slugs = await getSlugs();
return slugs.map((slug) => ({ slug }));
}
export default async function NewsPost({ params }: PageProps<"/news/[slug]">) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<MDXRemote source={post.body} options={{ mdxOptions: { format: "md" } }} />
</article>
);
}
format: "md". The compiler reads the text as plain Markdown: expressions and components stay text or are left out. The recording showed {process.env.SHOP_DB_PASSWORD} as text instead of the secret.
A project copied this next.config.ts from a guide for webpack. With Turbopack, the build stops with does not have serializable options. What goes in the plugin lists instead of the functions?
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
import remarkFrontmatter from "remark-frontmatter";
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
import rehypePrettyCode from "rehype-pretty-code";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
pageExtensions: ["ts", "tsx", "mdx"],
};
const withMDX = createMDX({
options: {
remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
rehypePlugins: [[rehypePrettyCode, { theme: "github-light" }]],
},
});
export default withMDX(nextConfig);
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
pageExtensions: ["ts", "tsx", "mdx"],
};
const withMDX = createMDX({
options: {
remarkPlugins: ["remark-frontmatter", "remark-mdx-frontmatter"],
rehypePlugins: [["rehype-pretty-code", { theme: "github-light" }]],
},
});
export default withMDX(nextConfig);
Their names, as strings: "remark-frontmatter", "remark-mdx-frontmatter", and ["rehype-pretty-code", { theme: "github-light" }] with its options. Then remove the imports. The recording of this version built and colored the code.
Check your understanding
Question 1 of 7A project has @next/mdx and
pageExtensions, but nomdx-components.tsx. What happens?Choose an answer to see the explanation.
Question 2 of 7This
next.config.tsbuilds with webpack. With Turbopack, Next.js 16's default bundler, what doesnext builddo?Read the code, then predictTypeScriptimport type { NextConfig } from "next"; import createMDX from "@next/mdx"; import remarkFrontmatter from "remark-frontmatter"; import remarkMdxFrontmatter from "remark-mdx-frontmatter"; import rehypePrettyCode from "rehype-pretty-code"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, pageExtensions: ["ts", "tsx", "mdx"], }; const withMDX = createMDX({ options: { remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter], rehypePlugins: [[rehypePrettyCode, { theme: "github-light" }]], }, }); export default withMDX(nextConfig);Choose an answer to see the explanation.
Question 3 of 7A post starts with a YAML block between
---lines, andnext.config.tsnames no plugins. What does the page show?Choose an answer to see the explanation.
Question 4 of 7Where does
{2 * 40}in a post from the CMS run whenMDXRemotefromnext-mdx-remote-client/rscrenders it?Choose an answer to see the explanation.
Question 5 of 7Someone with a CMS account adds
{process.env.SHOP_DB_PASSWORD}to a post that this page renders. What do visitors see?Read the code, then predictTSXimport { MDXRemote } from "next-mdx-remote-client/rsc"; import { notFound } from "next/navigation"; import { Price } from "@/components/price"; import { getPost, getSlugs } from "@/lib/cms"; export async function generateStaticParams() { const slugs = await getSlugs(); return slugs.map((slug) => ({ slug })); } export default async function NewsPost({ params }: PageProps<"/news/[slug]">) { const { slug } = await params; const post = await getPost(slug); if (!post) notFound(); return ( <article> <h1>{post.title}</h1> <MDXRemote source={post.body} components={{ Price }} /> </article> ); }Choose an answer to see the explanation.
Question 6 of 7rehype-pretty-code colored a code block during the build. What does the browser download to show the colors?
Choose an answer to see the explanation.
Question 7 of 7
content/tea-timer.mdxhasexport const metadata = { title: "A tea timer" }, and a dynamic page imports it. Where does the tab's title come from?Choose an answer to see the explanation.
Key takeaways
- MDX is Markdown that can hold JSX.
@next/mdxcompiles each.mdxfile into a React component during the build. Imported by a page or another Server Component, it's a Server Component; imported from a"use client"file, it's client code. - Setup: the four packages,
createMDXaround the settings,mdxinpageExtensions(which replaces the default list), andmdx-components.tsx, which the App Router requires. mdx-components.tsxmaps element names to your components for every post, likeatoLink.- A
page.mdxis a page with its ownmetadata. A file incontentis imported by a page, which reads its exports ingenerateMetadata. - YAML frontmatter needs
remark-frontmatterandremark-mdx-frontmatter, and a type for thefrontmatterexport. With Turbopack, plugins are named as strings, with plain options. - rehype-pretty-code colors code in the build: colored HTML, and no highlighter in the browser.
- Remote MDX runs as code on your server. Render it only from people who already deploy your code, reviewed like code; render every other CMS text, support articles included, as plain Markdown.
Remember the one-liner.
MDX is code that looks like text: compile it in the build, map its elements in mdx-components.tsx, and only run what you trust.
The plugin is packages/next-mdx/index.js, and its loader, which imports plugins by name, is mdx-js-loader.js. Where the compiled posts find useMDXComponents is in next_import_map.rs, all at release v16.4.3.
Coming next: Draft mode and CMS previews. Editors want to see a post on the real site before they publish it, without the cached copy everyone else gets.