How to get MDX working in Next.js — covers @next/mdx, next-mdx-remote, and mdx-bundler for both App Router and Pages Router.
Next.js has solid MDX support out of the box. You've got a few different paths depending on your setup — local files, remote content, or content that needs its own imports. Let's go through each one.
Installation Options
Three main ways to do this:
- @next/mdx — Official Next.js plugin (local MDX files)
- next-mdx-remote — For content from a CMS, database, or content folder
- mdx-bundler — Full bundling with component imports inside MDX
Using MDX in the App Router (app/ Directory)
File Structure
app/
├── layout.tsx
├── page.tsx
├── mdx-components.tsx ← Required for @next/mdx
├── blog/
│ ├── page.mdx ← MDX as a page
│ └── welcome/
│ └── page.mdx
└── docs/
└── getting-started/
└── page.mdx
mdx-components.tsx (Required)
You need this file at the root of your project. It tells Next.js how to render MDX elements:
// mdx-components.tsx
import type { MDXComponents } from 'mdx/types'
import Image, { ImageProps } from 'next/image'
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
// Override default HTML elements
h1: ({ children }) => (
<h1 className="text-4xl font-bold mt-8 mb-4">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-3xl font-semibold mt-6 mb-3">{children}</h2>
),
p: ({ children }) => (
<p className="text-gray-700 leading-relaxed mb-4">{children}</p>
),
img: (props) => (
<Image
sizes="100vw"
style={{ width: '100%', height: 'auto' }}
{...(props as ImageProps)}
/>
),
...components,
}
}
MDX Page with Metadata (App Router)
{/* app/blog/welcome/page.mdx */}
export const metadata = {
title: 'Welcome to My Blog',
description: 'An introduction to our MDX-powered blog',
}
# Welcome to My Blog
This page is written entirely in MDX and rendered as a Next.js page.
<CustomAlert type="info">
This component is defined in mdx-components.tsx
</CustomAlert>
Using MDX in the Pages Router
File Structure
pages/
├── _app.tsx
├── index.tsx
├── blog/
│ └── hello-world.mdx ← MDX page
└── docs/
└── intro.mdx
Layout Wrapper for Pages Router
// pages/blog/hello-world.mdx
import BlogLayout from '../../components/BlogLayout'
export const meta = {
title: 'Hello World',
date: '2024-01-15',
author: 'John Doe',
}
# Hello World
This is my first blog post using MDX in the Pages Router.
export default ({ children }) => <BlogLayout meta={meta}>{children}</BlogLayout>
Option 2: next-mdx-remote
This is what you want when your content lives outside the codebase — in a CMS, database, or separate content folder.
npm install next-mdx-remote
App Router Usage (Server Components)
// app/blog/[slug]/page.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'
import { readFile } from 'fs/promises'
import path from 'path'
import matter from 'gray-matter'
interface BlogPostProps {
params: { slug: string }
}
export default async function BlogPost({ params }: BlogPostProps) {
const filePath = path.join(process.cwd(), 'content', `${params.slug}.mdx`)
const source = await readFile(filePath, 'utf-8')
const { content, data } = matter(source)
return (
<article>
<h1>{data.title}</h1>
<time>{data.date}</time>
<MDXRemote
source={content}
components={{
// Custom components available in MDX
Alert: ({ children, type }) => (
<div className={`alert alert-${type}`}>{children}</div>
),
}}
/>
</article>
)
}
Pages Router Usage
// pages/blog/[slug].tsx
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote'
import { GetStaticProps, GetStaticPaths } from 'next'
interface Props {
source: MDXRemoteSerializeResult
frontmatter: Record<string, any>
}
export default function BlogPost({ source, frontmatter }: Props) {
return (
<article>
<h1>{frontmatter.title}</h1>
<MDXRemote {...source} components={components} />
</article>
)
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const filePath = path.join(process.cwd(), 'content', `${params!.slug}.mdx`)
const source = fs.readFileSync(filePath, 'utf-8')
const { content, data } = matter(source)
const mdxSource = await serialize(content, {
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [],
},
scope: data,
})
return { props: { source: mdxSource, frontmatter: data } }
}
export const getStaticPaths: GetStaticPaths = async () => {
const files = fs.readdirSync(path.join(process.cwd(), 'content'))
const paths = files.map((file) => ({
params: { slug: file.replace('.mdx', '') },
}))
return { paths, fallback: false }
}
Option 3: mdx-bundler
This is for advanced setups where your MDX files need to import their own components or modules:
npm install mdx-bundler esbuild
// lib/mdx.ts
import { bundleMDX } from 'mdx-bundler'
import path from 'path'
export async function getCompiledMDX(slug: string) {
const source = fs.readFileSync(
path.join(process.cwd(), 'content', `${slug}.mdx`),
'utf-8'
)
const { code, frontmatter } = await bundleMDX({
source,
cwd: path.join(process.cwd(), 'content'),
mdxOptions(options) {
options.remarkPlugins = [...(options.remarkPlugins ?? [])]
options.rehypePlugins = [...(options.rehypePlugins ?? [])]
return options
},
esbuildOptions(options) {
options.platform = 'node'
return options
},
})
return { code, frontmatter }
}
TypeScript Configuration
Add MDX type declarations so TypeScript knows what .mdx files export:
// types/mdx.d.ts
declare module '*.mdx' {
import type { MDXComponents } from 'mdx/types'
export const metadata: {
title: string
description: string
date: string
[key: string]: any
}
export default function MDXContent(props: {
components?: MDXComponents
}): JSX.Element
}
Update tsconfig.json:
{
"compilerOptions": {
"paths": {
"@/content/*": ["./content/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", "**/*.mdx"]
}
Adding Remark and Rehype Plugins
Plugins give you things like GitHub Flavored Markdown, auto-linked headings, and syntax highlighting:
// next.config.mjs
import createMDX from '@next/mdx'
import remarkGfm from 'remark-gfm'
import remarkFrontmatter from 'remark-frontmatter'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypePrism from 'rehype-prism-plus'
const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm, remarkFrontmatter],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
rehypePrism,
],
},
})
export default withMDX(nextConfig)
Summary
| Approach | Best For | Complexity |
|---|
| @next/mdx | Local MDX files as pages | Low |
| next-mdx-remote | CMS/remote content | Medium |
| mdx-bundler | MDX with imports | High |
Pick @next/mdx for simple docs sites, next-mdx-remote for blogs with a content directory, and mdx-bundler when your MDX files need to import their own components or modules.