How to Set Up MDX with the Next.js App Router (Step-by-Step)
A practical guide to configuring MDX in the Next.js App Router, covering file-based routing, remote MDX, custom components, and frontmatter parsing.
You have a Next.js project on the App Router and you want to write content in MDX — mixing Markdown prose with custom React components. The official docs cover the basics, but the real setup questions come up fast: how do you handle frontmatter, remote MDX from a CMS, custom component overrides, and TypeScript types without the process blowing up at build time?
This guide walks through a production-ready MDX setup from scratch — no boilerplate skipped.
Setting Up MDX in Next.js App Router
Install the required packages
The App Router uses @next/mdx paired with @mdx-js/react. You will also want next-mdx-remote if you plan to fetch MDX content from a CMS or database at runtime rather than storing it as local files.
npm install @next/mdx @mdx-js/react @types/mdxFor remote MDX (content stored outside the repo):
npm install next-mdx-remoteConfigure next.config.mjs
Wrap your Next.js config with the MDX plugin and set the page extensions so .mdx files are treated as routes:
import createMDX from '@next/mdx'
const withMDX = createMDX({
options: {
remarkPlugins: [],
rehypePlugins: [],
},
})
export default withMDX({
pageExtensions: ['ts', 'tsx', 'md', 'mdx'],
})Add the MDX components provider
Create a file at mdx-components.tsx in the project root. This is required by the App Router — without it, @next/mdx will not apply custom component overrides.
import type { MDXComponents } from 'mdx/types'
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
h2: ({ children }) => (
<h2 className="text-2xl font-semibold mt-8 mb-4">{children}</h2>
),
...components,
}
}Any HTML element you map here will override the default rendering for every MDX file in the project.
Handling Frontmatter
The App Router does not parse YAML frontmatter out of the box. Two reliable approaches:
Option A — gray-matter at build time. Read the .mdx file with fs.readFileSync, pass it through gray-matter, and render the body separately. This is the simplest path for local file-based blogs.
Option B — remark-frontmatter + remark-mdx-frontmatter. Add both as remarkPlugins. The frontmatter becomes an exported metadata object you can import directly from the MDX module. This works better when you want the frontmatter available inside the MDX itself.
import remarkFrontmatter from 'remark-frontmatter'
import remarkMdxFrontmatter from 'remark-mdx-frontmatter'
// inside createMDX options:
remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter]File-Based Routing for Blog Posts
The simplest structure for a blog:
app/
blog/
[slug]/
page.tsx
content/
blog/
my-first-post.mdx
In page.tsx, read the MDX file at request time:
import { readFile } from 'fs/promises'
import path from 'path'
import matter from 'gray-matter'
import { MDXRemote } from 'next-mdx-remote/rsc'
export default async function BlogPost({ params }: { params: { slug: string } }) {
const filePath = path.join(process.cwd(), 'content/blog', `${params.slug}.mdx`)
const raw = await readFile(filePath, 'utf-8')
const { content } = matter(raw)
return <MDXRemote source={content} />
}next-mdx-remote/rsc is the React Server Component version — no client bundle overhead.
Custom Components Inside MDX
You can pass custom components as a prop to MDXRemote:
const components = {
Callout: ({ children }: { children: React.ReactNode }) => (
<div className="border-l-4 border-blue-500 pl-4 my-4">{children}</div>
),
}
<MDXRemote source={content} components={components} />Any component you register this way becomes available inside .mdx files without an import. Keep this list small — every component in the map is included in the render path.
Generating Static Params
For static export or ISR, generate the list of slugs at build time:
export async function generateStaticParams() {
const files = await readdir(path.join(process.cwd(), 'content/blog'))
return files
.filter((f) => f.endsWith('.mdx'))
.map((f) => ({ slug: f.replace(/\.mdx$/, '') }))
}Common Mistakes to Avoid
- Forgetting
mdx-components.tsxin the project root. The file must be at root level, not insideapp/orsrc/. - Using JSX syntax inside MDX without registering the component. MDX will compile, but the component will be undefined at runtime.
- Skipping
rehype-pretty-codeorshiki. Raw code blocks in MDX have no syntax highlighting unless you wire up a rehype plugin. - Mixing
@next/mdxfile routing andnext-mdx-remoteon the same files. Pick one approach and stay consistent.
Syntax Highlighting
Install rehype-pretty-code and shiki:
npm install rehype-pretty-code shikiAdd it to your rehype plugins in next.config.mjs. Code blocks in your MDX will render with full token-level highlighting on the server, zero client JavaScript.
A well-structured MDX setup is the foundation of any serious content system built on Next.js. If you are building a content-heavy product — documentation, a blog platform, or a marketing site that engineers maintain — the choices you make here compound over time.
If you want to build it right from the start, talk to the Clixo team about content system architecture for your product.