MDX Complete Guide: Markdown + JSX for Developers

If you've ever wished Markdown could render a live code editor, an interactive chart, or a custom callout component — that's exactly what MDX is for.
MDX is a file format that merges Markdown's writing ergonomics with the full power of JSX components. It's the backbone of documentation sites like Nextra, Docusaurus, and countless personal developer blogs — including this one.
By the end of this guide, you will be able to:
✅ What MDX is and how it differs from Markdown
✅ MDX syntax: JSX in Markdown and Markdown in JSX
✅ Setting up MDX with Next.js App Router
✅ Remark and rehype plugin pipeline
✅ Building custom components (callouts, code tabs, diagrams)
✅ Frontmatter and metadata handling
✅ MDX vs alternatives (MDsveX, Markdoc, Contentlayer)
✅ Real-world patterns and best practices
What Is MDX?
MDX is a file format that lets you write JSX inside Markdown documents. Think of it as Markdown with superpowers: you get all the ergonomics of Markdown (headings, lists, code blocks) plus the composability of React components.
A minimal MDX file looks like this:
# Hello World
This is **Markdown** as usual.
<MyComponent greeting="Hello from JSX!" />
And we're back to _Markdown_ below the component.The .mdx extension signals to the toolchain that this file should be processed as MDX rather than plain Markdown. Under the hood, MDX compiles to JavaScript — each MDX file becomes a React component that you can import and render like any other.
The Problem MDX Solves
Static Markdown is excellent for content-heavy writing. It's easy to read in source form, portable, and universally understood. But it hits clear limits as soon as you want anything interactive.
Want a tabbed code block so readers can switch between JavaScript and TypeScript examples? You can't do that in plain Markdown. Need a styled warning callout with an icon? You'd have to write raw HTML — which is ugly in source and can't use your design system. Want to embed a live chart pulling from an API? Impossible.
Raw JSX solves all of that, but it's verbose for prose-heavy content. Writing an entire blog post in JSX means wrapping every paragraph in a <p> tag and escaping special characters constantly.
MDX is the sweet spot: prose in Markdown, interactivity in JSX. You write paragraphs and headings naturally. When you need a custom component, you drop it in. Everything else stays clean.
MDX vs Plain Markdown
| Feature | Markdown | MDX |
|---|---|---|
| Prose / headings / lists | ✅ | ✅ |
| Code blocks (static) | ✅ | ✅ |
| Inline HTML | ✅ | ✅ (limited) |
| React components | ❌ | ✅ |
| JavaScript expressions | ❌ | ✅ |
| Import/export | ❌ | ✅ |
| Dynamic content | ❌ | ✅ |
The "limited" note on inline HTML is important: MDX does support some inline HTML, but full HTML is not always passed through safely depending on the rehype configuration. For rich interactivity, always prefer JSX components over raw HTML in MDX.
MDX Syntax Deep Dive
Importing and Using Components
One of the core features of MDX is the ability to import React components at the top of the file, just like in a .tsx or .jsx file. Those components are then available anywhere in the document body.
import { Callout } from '../components/Callout'
import { Chart } from '../components/Chart'
# My Post
<Callout type="warning">
This is a **warning** inside a React component, inside MDX.
</Callout>
Here's quarterly data:
<Chart data={[10, 25, 40, 35]} />Note that Markdown still works normally between and around the components. The MDX compiler knows to treat content between blank lines as prose and JSX blocks as components.
JavaScript Expressions
You can embed JavaScript expressions directly in MDX using curly braces {}. This works for any valid JavaScript expression — not statements, but expressions that resolve to a value.
export const year = new Date().getFullYear()
This guide was written in {year}.
{/* This is a comment — it will not appear in the output */}
The answer is {40 + 2}.export statements at the top level of an MDX file define variables that can be used in the document body and also exported for the consuming page to read — which is useful for metadata.
Exporting Metadata (Frontmatter Alternative)
Before YAML frontmatter became standard in MDX tooling, the community used export const to attach metadata to an MDX file:
export const meta = {
title: 'My Post',
date: '2026-02-25',
tags: ['mdx', 'react'],
}
# My Post
Content here...Today, most tooling (gray-matter, next-mdx-remote with parseFrontmatter: true) supports YAML frontmatter directly. But you may encounter the export const meta pattern in older codebases.
Mixing Prose and Components
The key rules for mixing Markdown prose and JSX components in MDX:
- Blank lines separate Markdown paragraphs from JSX blocks
- JSX blocks must start at the beginning of the line (no leading spaces)
- Self-closing JSX tags work:
<MyComponent /> - Children work normally:
<Box>content here</Box> - Markdown inside JSX children is processed recursively
Here is a more complex example showing nested structure. In your .mdx source file, a tabs component looks like this:
Here is some text before the tabs component.
[Tabs]
[Tab label="JavaScript"]
(javascript code block here)
[/Tab]
[Tab label="TypeScript"]
(typescript code block here)
[/Tab]
[/Tabs]
Back to regular prose after the component.(Angle brackets are shown as square brackets above to avoid confusing the MDX parser in this very post — the actual syntax uses <Tabs>, <Tab>, and </Tabs>.)
This pattern — wrapping code blocks in a custom <Tabs> component — is one of the most common uses of MDX in technical documentation.
Setting Up MDX with Next.js App Router
There are three main approaches to using MDX in a Next.js project. The right choice depends on where your content lives and how much flexibility you need.
Method 1: @next/mdx (Official, Simple)
The official Next.js MDX integration treats .mdx files in the app/ directory as pages. This is the simplest setup and the right choice if your content and code live together.
npm install @next/mdx @mdx-js/loader @mdx-js/react
npm install -D @types/mdxConfigure next.config.mjs:
import createMDX from '@next/mdx'
const withMDX = createMDX({
options: {
remarkPlugins: [],
rehypePlugins: [],
},
})
export default withMDX({
// Tell Next.js to treat .mdx files as pages
pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
})Now create app/blog/my-post/page.mdx and it automatically becomes the route /blog/my-post. You can also create mdx-components.tsx at the project root to provide global custom components.
Method 2: next-mdx-remote (For Content Outside app/)
This is the pattern used in this blog — content lives in content/posts/ completely separate from the Next.js app router. A dynamic route page fetches and compiles the MDX at request time (or build time with static generation).
npm install next-mdx-remote// app/[locale]/blog/[slug]/page.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'
import { getPostBySlug } from '@/lib/posts'
export default async function PostPage({
params,
}: {
params: { slug: string; locale: string }
}) {
const { content, frontmatter } = await getPostBySlug(params.slug, params.locale)
return (
<article>
<h1>{frontmatter.title}</h1>
<MDXRemote source={content} components={mdxComponents} />
</article>
)
}// lib/mdx-components.tsx — custom components available in all MDX files
const mdxComponents = {
Callout,
CodeTabs,
// Override default HTML elements with custom implementations:
pre: CustomCodeBlock,
a: CustomLink,
img: CustomImage,
}The /rsc import suffix is important — it uses React Server Components, which means MDX compilation happens on the server with zero client-side JavaScript overhead for the compilation itself.
Method 3: Vite + MDX Plugin
For projects outside Next.js — Remix, plain React + Vite, or SPA frameworks — the @mdx-js/rollup plugin integrates MDX into Vite's build pipeline.
npm install -D @mdx-js/rollup// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import mdx from '@mdx-js/rollup'
export default defineConfig({
plugins: [
// mdx() must come before react()
mdx(),
react(),
],
})After this, you can import .mdx files directly as React components:
import MyPost from './posts/my-post.mdx'
export default function App() {
return <MyPost />
}The Plugin Pipeline: Remark and Rehype
MDX processing is not a single-pass transformation. It runs your content through a two-stage AST (Abstract Syntax Tree) pipeline. Understanding this pipeline is essential for configuring MDX correctly and writing your own plugins.
Stage 1 — remark (Markdown AST): The MDX source text is parsed into a Markdown AST called mdast. Remark plugins operate on this tree. They can add, remove, or transform nodes before the tree is converted to HTML.
Stage 2 — rehype (HTML AST): The mdast is converted to an HTML AST called hast. Rehype plugins operate here. They work with HTML-level nodes — useful for syntax highlighting, heading IDs, and link transformations.
Remark Plugins (Markdown Stage)
These plugins process your content at the Markdown level — before it becomes HTML:
- remark-gfm: GitHub Flavored Markdown — tables, strikethrough, task lists, autolinks, and footnotes. Nearly every MDX project uses this.
- remark-math: LaTeX-style math expressions with
$inline$and$$block$$syntax. - remark-toc: Automatically generates a table of contents from headings.
- remark-frontmatter: Parses YAML or TOML frontmatter so the AST knows about it (pairs with remark-mdx-frontmatter to extract values).
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
const options = {
remarkPlugins: [remarkGfm, remarkMath],
}Rehype Plugins (HTML Stage)
These plugins process the HTML AST — after Markdown has been converted to HTML:
- rehype-pretty-code: Beautiful syntax highlighting powered by Shiki. Supports 100+ languages and dozens of themes including
github-dark,dracula, andnord. - rehype-slug: Automatically generates
idattributes for all headings based on their text content, enabling anchor links. - rehype-autolink-headings: Adds
<a>anchor links to each heading (works with rehype-slug). - rehype-katex: Renders math expressions from remark-math into display-ready KaTeX HTML.
- rehype-sanitize: Strips dangerous HTML attributes and tags — essential if you allow any user-provided content.
import rehypeSlug from 'rehype-slug'
import rehypePrettyCode from 'rehype-pretty-code'
const options = {
rehypePlugins: [
// Add IDs to headings first
rehypeSlug,
// Then apply syntax highlighting
[rehypePrettyCode, { theme: 'github-dark' }],
],
}Plugin order matters. Some plugins depend on the output of others — rehype-autolink-headings, for example, needs rehype-slug to run first so the heading IDs already exist when it goes to add links.
Building Custom Components
Custom components are where MDX really shines. These are the building blocks that transform a plain blog into an interactive documentation experience.
Callout Component
A callout is a styled box used to highlight important information — warnings, tips, notes, or errors. Here's a fully typed TypeScript implementation:
// components/Callout.tsx
type CalloutType = 'info' | 'warning' | 'error' | 'tip'
const styles: Record<CalloutType, string> = {
info: 'bg-blue-50 border-blue-400 text-blue-800',
warning: 'bg-yellow-50 border-yellow-400 text-yellow-800',
error: 'bg-red-50 border-red-400 text-red-800',
tip: 'bg-green-50 border-green-400 text-green-800',
}
const icons: Record<CalloutType, string> = {
info: 'ℹ️',
warning: '⚠️',
error: '❌',
tip: '💡',
}
export function Callout({
type = 'info',
children,
}: {
type?: CalloutType
children: React.ReactNode
}) {
return (
<div className={`border-l-4 p-4 my-4 rounded-r ${styles[type]}`}>
<span className="mr-2">{icons[type]}</span>
{children}
</div>
)
}Usage in an MDX file:
<Callout type="warning">
Always validate user input before passing it to MDX rendering.
</Callout>
<Callout type="tip">
Use `short_description` in frontmatter for cleaner OG image text.
</Callout>Tabbed Code Block
This is the component readers see most on documentation sites — tabs that let you switch between language examples without scrolling:
// components/CodeTabs.tsx
'use client'
import { useState } from 'react'
type Tab = {
label: string
children: React.ReactNode
}
export function CodeTabs({ tabs }: { tabs: Tab[] }) {
const [active, setActive] = useState(0)
return (
<div className="rounded-lg border overflow-hidden my-4">
<div className="flex border-b bg-gray-50 dark:bg-gray-800">
{tabs.map((tab, i) => (
<button
key={tab.label}
onClick={() => setActive(i)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
active === i
? 'border-b-2 border-blue-500 text-blue-600'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400'
}`}
>
{tab.label}
</button>
))}
</div>
<div>{tabs[active].children}</div>
</div>
)
}Note the 'use client' directive — any component using useState, useEffect, or other React hooks must be a Client Component in Next.js App Router. The MDX file itself remains server-rendered; only the interactive tab-switching logic runs client-side.
Overriding Default Elements
One of MDX's most powerful features is the ability to replace default HTML elements with your own React components globally — without changing any MDX source files.
// mdx-components.tsx (Next.js convention — place at project root)
import type { MDXComponents } from 'mdx/types'
import Image from 'next/image'
import Link from 'next/link'
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
// Replace <img> with Next.js Image for automatic optimization
img: ({ src, alt, ...props }) => (
<Image
src={src!}
alt={alt ?? ''}
width={800}
height={400}
className="rounded-lg"
{...props}
/>
),
// Replace <a> with Next.js Link for client-side navigation
a: ({ href, children, ...props }) => (
<Link href={href ?? '#'} {...props}>
{children}
</Link>
),
// Replace <pre> with a custom code block that adds a copy button
pre: CustomCodeBlock,
// Merge with any per-file component overrides
...components,
}
}When MDX encounters  in your Markdown, it renders as <img>. With this override in place, it renders as <Image> from next/image instead — automatically getting lazy loading, size optimization, and blur placeholders. All without touching your MDX content files.
Frontmatter Handling
MDX itself does not natively parse YAML frontmatter. The --- block at the top of an MDX file is just text unless you configure a plugin or a library to handle it.
With gray-matter (Most Common)
gray-matter is the de-facto standard for parsing frontmatter in Node.js. It strips the YAML block and returns it as a JavaScript object:
// lib/posts.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
export async function getPostBySlug(slug: string, locale: string) {
const filePath = path.join(
process.cwd(),
'content/posts',
locale,
`${slug}.mdx`
)
const rawContent = fs.readFileSync(filePath, 'utf-8')
// matter() strips the frontmatter and returns it separately
const { data: frontmatter, content } = matter(rawContent)
return { frontmatter, content }
}The content returned by matter() is the MDX body without the frontmatter block — ready to be passed to next-mdx-remote or compiled directly.
With next-mdx-remote and parseFrontmatter
next-mdx-remote v5+ can parse frontmatter natively during compilation, with TypeScript generics for full type safety:
import { compileMDX } from 'next-mdx-remote/rsc'
// Define the shape of your frontmatter
type PostFrontmatter = {
title: string
description: string
date: string
tags: string[]
}
const { content, frontmatter } = await compileMDX<PostFrontmatter>({
source: rawContent,
options: { parseFrontmatter: true },
components: mdxComponents,
})
// frontmatter is now typed as PostFrontmatter
console.log(frontmatter.title) // string
console.log(frontmatter.tags) // string[]MDX vs Alternatives
Before committing to MDX, it is worth understanding the alternatives and what trade-offs each one makes.
| Feature | MDX | MDsveX | Markdoc | Contentlayer |
|---|---|---|---|---|
| Framework | React/any | Svelte | Any (via tags) | Any |
| JSX in content | ✅ Full JSX | ✅ Svelte components | ⚠️ Custom tags only | ❌ |
| JS expressions | ✅ | ✅ | ❌ | ❌ |
| Type safety | ⚠️ Manual | ⚠️ Manual | ✅ Schema-based | ✅ Schema-based |
| Security | ⚠️ Risky for user content | ⚠️ Risky | ✅ Safe by design | ✅ |
| Learning curve | Medium | Medium | Low | Low |
| Ecosystem | Large | Small | Growing | Archived |
MDsveX is MDX's direct equivalent in the Svelte ecosystem. If you're building with SvelteKit, MDsveX gives you the same Markdown + component experience.
Markdoc (by Stripe) takes a different philosophy: it uses custom tags instead of arbitrary JSX, which makes it safer and more predictable — but also more constrained. You can't write arbitrary JavaScript expressions in Markdoc content.
Contentlayer was a popular type-safe content SDK that generated TypeScript types from your MDX frontmatter schemas automatically. It has been archived and is no longer actively maintained as of 2024.
When to Use MDX
- Documentation sites for component libraries or developer tools (Nextra, Docusaurus)
- Technical blogs where you as the developer control all content
- Interactive tutorials with embedded live code editors or demos
- Landing pages that mix prose content with custom UI components
When NOT to Use MDX
- User-generated content: MDX compiles to JavaScript and executes it. Rendering untrusted MDX is a serious security vulnerability — it gives attackers arbitrary code execution.
- Simple blogs that don't need components: If your posts are just text, headings, and code blocks, plain Markdown with a good remark/rehype pipeline is simpler and faster.
- CMS-driven content where editors aren't developers: Non-technical editors will find MDX syntax confusing. Markdoc or a headless CMS are better fits.
Real-World Patterns
Pattern 1: Static Component Injection (No Imports Needed)
Requiring every MDX file to import the components it uses creates maintenance overhead — if you rename a component, you have to update every file. The better pattern is to inject commonly used components globally:
// All MDX files can now use <Callout>, <Note>, <Warning>, <CodeTabs>
// without a single import statement in the MDX file
const globalComponents = {
Callout,
Note,
Warning,
CodeTabs,
}
<MDXRemote source={content} components={globalComponents} />This is the recommended approach for design-system components that appear across many posts.
Pattern 2: Code Block with Filename Display
rehype-pretty-code supports a metadata string after the language identifier. You can use this to show a filename above the code block. Append filename="your-file.ts" right after the language name on the opening fence line:
export async function getPostBySlug(slug: string) {
// Implementation here — the fence line above would be:
// ```typescript filename="lib/posts.ts"
}The filename attribute is extracted by rehype-pretty-code and added as a data attribute on the <pre> element, which you can style with CSS to display as a tab or label.
Pattern 3: Lazy Loading Heavy Components
Interactive components — charts, code playgrounds, 3D renderers — can add significant JavaScript to your client bundle. Lazy load them to keep initial page loads fast:
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
loading: () => <p>Loading chart...</p>,
ssr: false, // Disable server rendering for browser-only components
})
<HeavyChart data={salesData} />With ssr: false, the component is excluded from the server render entirely and loaded only in the browser after hydration.
Pattern 4: MDX with Data Fetching (Next.js RSC)
In Next.js App Router, any component that doesn't have 'use client' is a Server Component by default. This means your MDX components can fetch data directly — no client-side state, no useEffect, no loading spinners:
// components/LiveStats.tsx — Server Component (no 'use client' needed)
async function LiveStats() {
// This fetch runs on the server at build time or request time
const stats = await fetch('https://api.example.com/stats', {
next: { revalidate: 3600 }, // Revalidate every hour
}).then(r => r.json())
return (
<div className="stats-card">
<span>Active users: {stats.activeUsers}</span>
</div>
)
}You can then use <LiveStats /> in any MDX file, and the data will be fetched on the server at build time or per-request — with no JavaScript shipped to the browser for the data fetching logic.
Security Considerations
MDX is a powerful tool, but its power comes with a significant security caveat: MDX compiles source text to executable JavaScript. This is not a problem when you control the source — but it is a critical vulnerability if you ever render content from untrusted sources.
// ❌ DANGEROUS — never do this with user-submitted content
const userComment = await db.getComment(id) // Text submitted by a user
<MDXRemote source={userComment} /> // This executes the user's JS!
// ✅ Safe — only render MDX from your own content repository
const { content } = await getPostBySlug(slug, locale) // Content from your git repo
<MDXRemote source={content} /> // Safe to renderThe attack is straightforward: a malicious user submits MDX content containing <script> tags or JavaScript expressions that exfiltrate cookies, redirect the browser, or perform server-side code execution.
For user-generated content, use safer alternatives:
- Markdoc — safe by design, restricts content to a defined tag schema with no arbitrary JS
- react-markdown — renders Markdown only, no JSX execution whatsoever
- rehype-sanitize — if you must allow some HTML, this strips dangerous attributes and elements
The rule of thumb: MDX is for your content. Anything created by external users should use a sandboxed format.
Performance Tips
1. Cache Compiled MDX
Compiling MDX involves parsing, AST transformation, and code generation — it is not cheap. In Next.js App Router, the React cache() function memoizes the result per unique argument set:
import { cache } from 'react'
// Compilation result is cached per (slug, locale) pair within a single request
// For static generation, this avoids recompiling the same post multiple times
export const getPostBySlug = cache(async (slug: string, locale: string) => {
const { frontmatter, content } = await compilePost(slug, locale)
return { frontmatter, content }
})For persistent caching across requests, consider unstable_cache from Next.js or a key-value store like Redis if compilation latency is a bottleneck.
2. Static Generation
With Next.js, you can generate all post pages at build time using generateStaticParams. This means zero runtime compilation — all MDX is compiled once during the build and served as static HTML:
// app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts('en')
// Returns [{ slug: 'post-1' }, { slug: 'post-2' }, ...]
return posts.map(post => ({ slug: post.slug }))
}Combined with ISR (Incremental Static Regeneration), you can revalidate individual posts on demand when content changes — without rebuilding the entire site.
3. Bundle Size
Every component you import inside an MDX file (or inject globally) potentially increases client bundle size. Mitigate this by:
- Preferring Server Components — they run on the server and ship zero client JavaScript
- Using dynamic imports (
next/dynamic) for heavy interactive components - Keeping
'use client'components small and focused - Auditing your bundle with
@next/bundle-analyzer
The goal is to have rich interactivity where you need it without paying the JavaScript cost everywhere.
Summary
MDX fills a specific niche: developer-controlled content that needs React components. It is the right tool when your blog posts or documentation pages need to be more than static text — when you want live demos, styled callouts, interactive tables, or embedded charts alongside your prose.
Key takeaways:
✅ MDX = Markdown + JSX — prose ergonomics plus component composability
✅ Use remark plugins for Markdown transformations, rehype plugins for HTML/AST transformations
✅ next-mdx-remote is the go-to solution for Next.js blogs with content outside the app/ directory
✅ Override default HTML elements globally via mdx-components.tsx for consistent styling
✅ Never render untrusted user content as MDX — it executes arbitrary JavaScript
✅ Cache compiled MDX and use static generation for maximum performance
If you're building a personal blog with Next.js, MDX gives you a tremendous amount of flexibility with a relatively small learning curve. Start with the basics — a custom Callout component and syntax highlighting — and add complexity only when your content actually needs it.
Related Posts
Related posts:
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.