Using MDX in Next.js Static Export

MDX — Markdown that can import and render components — is why many content teams chose Next.js in the first place: writers get Markdown, developers get React components for callouts, charts, tabbed code samples and interactive demos. With the App Router and output: 'export', MDX still works, but the options have multiplied and the older tutorials describe Pages Router patterns. The choices that matter are how MDX is compiled (as pages, as imports, or as strings from files or a CMS), how frontmatter is read, which components run on the client, and what the plugins cost at build time.

This guide sets up MDX for a 280-post engineering blog in a Next.js 14 App Router static export, compares @next/mdx with next-mdx-remote, adds remark and rehype plugins for headings, links and code, keeps components server-rendered unless they need interactivity, and measures the build. It is part of Next.js Static Export for Content Sites.

Prerequisites

  • Next.js 14 or 15, App Router, output: 'export'.
  • MDX content in a content/ directory (or a CMS field containing MDX).
  • Familiarity with remark and rehype plugins; see Markdown Pipelines: remark vs markdown-it.

Two Ways to Compile MDX

@next/mdx plugs the MDX compiler into webpack. MDX files can be pages themselves (app/blog/hello/page.mdx) or be imported by pages (import Post from '@/content/hello.mdx'). Compilation happens during the Next.js build, cached by webpack, and imports of components are ordinary module imports. It suits sites where each MDX file is a known route.

next-mdx-remote (the /rsc entry point for the App Router) compiles MDX from a string at render time — which, in a static export, means at build time inside generateStaticParams-generated pages. It suits content read from a directory with dynamic routes, or MDX stored in a CMS, and makes frontmatter handling trivial with parseFrontmatter: true.

The blog uses next-mdx-remote/rsc, because posts live in content/posts/*.mdx behind a single app/blog/[slug]/page.tsx route, and the same compile function renders CMS-sourced MDX on landing pages.

Comparison of @next/mdx and next-mdx-remote for static export A matrix comparing two MDX setups. @next/mdx compiles through webpack, suits MDX files as routes or imports, needs extra plugins for frontmatter, cannot compile CMS strings, and benefits from webpack caching. next-mdx-remote compiles strings at build time, suits dynamic routes over a directory or CMS content, parses frontmatter built in, and needs its own caching. @next/mdx or next-mdx-remote? Aspect @next/mdx next-mdx-remote/rsc Source of MDX files as routes or imports files or CMS strings Frontmatter extra remark plugins parseFrontmatter option Dynamic routes over a folder possible, more wiring natural fit Build caching webpack cache roll your own
Both are fully static in an export; the choice follows where the MDX comes from.

Step 1: Load Posts and Frontmatter

// lib/posts.ts
import { readdir, readFile } from 'node:fs/promises';
import { compileMDX } from 'next-mdx-remote/rsc';
import { mdxOptions } from './mdx-options';
import { components } from '@/components/mdx';

export async function getPostSlugs() {
  return (await readdir('content/posts')).filter((f) => f.endsWith('.mdx')).map((f) => f.replace(/\.mdx$/, ''));
}

export async function getPost(slug: string) {
  const source = await readFile(`content/posts/${slug}.mdx`, 'utf8');
  return compileMDX<{ title: string; date: string; summary: string; draft?: boolean }>({
    source, components, options: { parseFrontmatter: true, mdxOptions }
  });
}
// app/blog/[slug]/page.tsx
export const dynamicParams = false;
export async function generateStaticParams() {
  return (await getPostSlugs()).map((slug) => ({ slug }));
}
export default async function Page({ params }: { params: { slug: string } }) {
  const { content, frontmatter } = await getPost(params.slug);
  return <article><h1>{frontmatter.title}</h1>{content}</article>;
}

dynamicParams = false makes an unknown slug a build-time 404 rather than an attempt at runtime rendering, which static export cannot do. Validate frontmatter with a schema (Zod works well) inside getPost; on this blog, validation found nine posts with dates in the wrong format and two with missing summaries.

Step 2: Plugins

// lib/mdx-options.ts
import remarkGfm from 'remark-gfm';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypePrettyCode from 'rehype-pretty-code';
import { rehypeInternalLinks } from './rehype-internal-links';

export const mdxOptions = {
  remarkPlugins: [remarkGfm],
  rehypePlugins: [
    rehypeSlug,
    [rehypeAutolinkHeadings, { behavior: 'append' }],
    [rehypePrettyCode, { theme: { light: 'github-light', dark: 'github-dark-dimmed' }, keepBackground: false }],
    [rehypeInternalLinks, { trailingSlash: true }]
  ]
};

rehype-pretty-code highlights with Shiki at build time, so no highlighting JavaScript ships — the same approach as Syntax Highlighting with Shiki in Astro and Eleventy. The small rehypeInternalLinks plugin adds trailing slashes to internal links, which next/link would do for component links but nothing does for Markdown links; the reason is explained in Trailing Slash and basePath in Next.js Static Export.

Step 3: Keep Components on the Server

In the App Router, MDX renders as React Server Components. Components passed in the components map render to HTML on the server and ship no JavaScript — unless they are client components. That makes the component map the place to control page weight:

// components/mdx.tsx
import Link from 'next/link';
import { Callout } from './Callout';          // server component: HTML only
import { Figure } from './Figure';            // server component
import { Chart } from './Chart';              // 'use client' — interactive
export const components = {
  a: (props) => props.href?.startsWith('/') ? <Link {...props} /> : <a {...props} rel="noopener" />,
  Callout, Figure, Chart
};

Of the blog's 11 MDX components, 9 are server components. Chart and a Playground component are client components, and they are only in the client bundle of posts that use them. A post with no interactive components loaded 81 KB of JavaScript — the Next.js runtime and React, which static export always includes — and none of it was MDX-related.

Client JavaScript on blog posts by which MDX components they use Horizontal bars of brotli-compressed JavaScript. A post with only server components loaded 81 kilobytes, the Next.js and React baseline. A post with the Chart client component loaded 124 kilobytes. A post with the Playground component loaded 163 kilobytes. Before the refactor, when all MDX components were client components, every post loaded 171 kilobytes. JavaScript per post by component use Before (all client components) 171 KB Post with Playground 163 KB Post with Chart 124 KB Server components only 81 KB
From the Lighthouse network table on representative posts, median of five runs.

Step 4: Build Cost and Caching

MDX compilation with a full plugin set is not cheap. Profiling the build with NEXT_TELEMETRY_DISABLED=1 next build --debug and timing the compile function:

Build stepTime
MDX compile, 280 posts (remark + rehype + Shiki)34 s
React server rendering of all pages19 s
Webpack bundling and other build work18 s
Total71 s

Shiki highlighting was two thirds of MDX compile time. Caching compiled output by content hash — store the serialized result keyed by a hash of the MDX source and the plugin configuration, in a directory persisted between CI runs with actions/cache — brought MDX compile time to 3 s on builds where a handful of posts changed. compileMDX returns React elements, which do not serialize; cache the intermediate compiled JavaScript from @mdx-js/mdx's compile instead and evaluate it with run, which is what the caching wrapper on this site does.

Next.js build time with cold and warm MDX compile cache Paired bars. MDX compile time was 34 seconds without a cache and 3 seconds with a warm cache when a few posts changed. Total build time was 71 seconds without a cache and 40 seconds with it. Build time with and without an MDX cache No cache Warm cache MDX compile 34 s 3 s Total build 71 s 40 s
Median of five runs on an 8-vCPU runner; the cache key includes the plugin configuration so plugin upgrades invalidate it.

Writers and MDX Errors

MDX is stricter than Markdown: an unescaped < or { in prose is parsed as JSX and fails the build with an error that writers find cryptic. Three things helped the blog's writers. A pre-commit check compiles changed MDX files and prints the file, line and a plain-language hint ("{ starts a JavaScript expression in MDX — write \{ or put it in backticks"). An editor extension (the MDX extension for VS Code) highlights syntax errors while typing. And posts that do not need components can be plain .md files, compiled with format: 'md', where < and { are ordinary characters.

Where content comes from a CMS as MDX strings, compile it with the same options and component map as file-based posts, and validate it in the CMS's preview step if possible, so an editor sees a compile error before publishing rather than as a failed production build.

Measured Impact

MeasurePages Router + next-mdx-remote (before)App Router + RSC MDX (after)
JavaScript on a typical post171 KB81 KB
LCP p75 on posts, field2.3 s1.8 s
Build time, warm cache88 s40 s
Posts with invalid frontmatter caught0 (not validated)11
Writer-reported MDX build failures per month72

Pitfalls & Rollback

  • use client on the whole component map. Every component then ships to every post; mark only interactive ones.
  • Relying on dynamicParams. Set it to false in static export so missing slugs fail the build.
  • Markdown links without slashes. Normalize them in a rehype plugin.
  • Caching React elements. Cache compiled code, not rendered output.
  • Rollback: MDX setup is confined to the content loader and component map; switching between @next/mdx and next-mdx-remote or reverting plugin changes does not affect routes.

Conclusion

MDX in a Next.js static export works well once compilation is fully build-time: next-mdx-remote/rsc for directory or CMS content, @next/mdx for file-based routes, server components by default, client components only for real interactivity, and a compile cache on larger sites. On this blog that halved JavaScript per post and the warm build time.

FAQ

Which MDX setup works with output export?

Both @next/mdx, which compiles MDX files as pages or imports at build time, and next-mdx-remote or its RSC variant, which compiles MDX strings from files or a CMS, work with static export as long as everything is compiled at build time and routes are enumerated with generateStaticParams.

How do I read frontmatter with @next/mdx?

@next/mdx does not parse frontmatter by default. Add remark-frontmatter and remark-mdx-frontmatter to expose it as an export, or keep metadata in an exported object in each MDX file. Many content sites instead read files with gray-matter and compile with next-mdx-remote.

Do MDX components add JavaScript to the page?

In the App Router, MDX compiles to React Server Components by default, so components render to HTML with no client JavaScript unless they are client components marked with use client. Interactive components should be the only client components in MDX content.

Is MDX compilation slow?

It is noticeably slower than plain Markdown. On a 280-post blog, compiling MDX with a typical plugin set and syntax highlighting took about 34 seconds of a 71-second build. Caching compiled output between builds helps on large sites.

Filed underHow-tosNext.js