MDX sits on top of the unified ecosystem. That means you get access to hundreds of remark and rehype plugins that transform your content at build time. Tables, math, syntax highlighting, auto-linked headings — you can add all of these without touching your MDX source files.
The Unified Pipeline
Here's how MDX processes your content:
!MDX Plugin Pipeline
// The MDX compilation pipeline
import { compile } from '@mdx-js/mdx';
const result = await compile(mdxSource, {
remarkPlugins: [/* Transform Markdown AST */],
rehypePlugins: [/* Transform HTML AST */],
recmaPlugins: [/* Transform JS AST (advanced) */],
});
Two plugin types to know:
- Remark plugins — work on the Markdown AST (before HTML conversion)
- Rehype plugins — work on the HTML AST (after conversion)
Remark Plugins
These run on the Markdown tree. They shape your content before it becomes HTML.
remark-gfm (GitHub Flavored Markdown)
Gives you tables, strikethrough, task lists, and autolinks:
// mdx.config.js
import remarkGfm from 'remark-gfm';
export default {
remarkPlugins: [remarkGfm],
};
Before (without remark-gfm):
| Feature | Support |
|---------|---------|
| Tables | ❌ Not rendered |
- [ ] Task lists don't work
- [x] Checkboxes are plain text
~~Strikethrough~~ is ignored.
After (with remark-gfm):
| Feature | Support |
|---------|---------|
| Tables | ✅ Fully rendered |
- [ ] Task lists render as checkboxes
- [x] Completed items are checked
~~Strikethrough~~ renders with line-through styling.
remark-math
Adds LaTeX math support to your MDX:
npm install remark-math rehype-katex katex
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
export default {
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
};
Inline math: $E = mc^2$
Block math:
$$
\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
$$
The quadratic formula: $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$
Note: You also need to import the KaTeX CSS in your layout: import 'katex/dist/katex.min.css'
remark-toc (Table of Contents)
Auto-generates a table of contents from your headings:
import remarkToc from 'remark-toc';
export default {
remarkPlugins: [
[remarkToc, {
heading: 'Table of Contents', // Marker heading
maxDepth: 3, // Include h1-h3
tight: true, // Compact list
ordered: false, // Unordered list
}],
],
};
## Table of Contents
{/* TOC is auto-generated here */}
## Introduction
Content here...
## Getting Started
More content...
### Installation
Sub-section...
remark-slug
Adds id attributes to headings so you can link to them:
import remarkSlug from 'remark-slug';
export default {
remarkPlugins: [remarkSlug],
};
Before: <h2>Getting Started</h2> After: <h2 id="getting-started">Getting Started</h2>
Rehype Plugins
These run after the Markdown becomes HTML. They transform the HTML tree.
rehype-highlight
Syntax highlighting via highlight.js:
npm install rehype-highlight
import rehypeHighlight from 'rehype-highlight';
export default {
rehypePlugins: [
[rehypeHighlight, {
languages: {
// Register additional languages
dockerfile: require('highlight.js/lib/languages/dockerfile'),
terraform: require('highlight.js/lib/languages/hcl'),
},
ignoreMissing: true, // Don't throw on unknown languages
aliases: {
javascript: ['js', 'mjs'],
typescript: ['ts', 'tsx'],
},
}],
],
};
rehype-katex
Renders the math nodes that remark-math parsed:
import rehypeKatex from 'rehype-katex';
export default {
rehypePlugins: [
[rehypeKatex, {
strict: false, // Don't throw on unsupported commands
throwOnError: false, // Render error inline instead of throwing
trust: true, // Allow \htmlClass and similar
macros: {
'\\R': '\\mathbb{R}',
'\\N': '\\mathbb{N}',
'\\vec': '\\mathbf',
},
}],
],
};
rehype-autolink-headings
Adds clickable anchor links next to your headings:
npm install rehype-autolink-headings
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
export default {
rehypePlugins: [
rehypeSlug, // Must come BEFORE autolink-headings
[rehypeAutolinkHeadings, {
behavior: 'prepend', // 'prepend' | 'append' | 'wrap' | 'before' | 'after'
properties: {
className: ['anchor-link'],
ariaHidden: true,
tabIndex: -1,
},
content: {
type: 'element',
tagName: 'span',
properties: { className: ['anchor-icon'] },
children: [{ type: 'text', value: '#' }],
},
}],
],
};
Writing Custom Plugins
Sometimes the existing plugins don't do what you need. Writing your own is surprisingly straightforward.
Custom Remark Plugin
This one converts GitHub-style callout blockquotes into React components:
// plugins/remark-callouts.js
import { visit } from 'unist-util-visit';
/**
* Transforms blockquotes with special markers into callout components.
* > [!NOTE] This is a note → <Callout type="note">...</Callout>
* > [!WARNING] Be careful → <Callout type="warning">...</Callout>
*/
export function remarkCallouts() {
return (tree) => {
visit(tree, 'blockquote', (node, index, parent) => {
const firstChild = node.children[0];
if (firstChild?.type !== 'paragraph') return;
const textNode = firstChild.children[0];
if (textNode?.type !== 'text') return;
const match = textNode.value.match(/^\[!(NOTE|WARNING|TIP|DANGER)\]\s*(.*)/);
if (!match) return;
const [, type, title] = match;
// Replace the blockquote with an MDX JSX element
parent.children[index] = {
type: 'mdxJsxFlowElement',
name: 'Callout',
attributes: [
{ type: 'mdxJsxAttribute', name: 'type', value: type.toLowerCase() },
{ type: 'mdxJsxAttribute', name: 'title', value: title },
],
children: node.children.slice(1), // Remove the marker paragraph
};
});
};
}
Custom Rehype Plugin
This parses code block meta strings (like title="app.js") into data attributes your CSS or components can target:
// plugins/rehype-code-meta.js
import { visit } from 'unist-util-visit';
/**
* Parses code block meta strings into data attributes.
* ```js title="app.js" highlight={[1,3]} showLineNumbers
* Becomes: <pre data-title="app.js" data-highlight="1,3" data-line-numbers>
*/
export function rehypeCodeMeta() {
return (tree) => {
visit(tree, 'element', (node) => {
if (node.tagName !== 'pre') return;
const codeEl = node.children.find(
(child) => child.tagName === 'code'
);
if (!codeEl) return;
const meta = codeEl.data?.meta || '';
if (!meta) return;
// Parse title="value" patterns
const titleMatch = meta.match(/title="([^"]+)"/);
if (titleMatch) {
node.properties['data-title'] = titleMatch[1];
}
// Parse highlight={[1,2,3]} patterns
const highlightMatch = meta.match(/highlight=\{?\[([^\]]+)\]\}?/);
if (highlightMatch) {
node.properties['data-highlight'] = highlightMatch[1];
}
// Parse boolean flags
if (meta.includes('showLineNumbers')) {
node.properties['data-line-numbers'] = true;
}
if (meta.includes('copy')) {
node.properties['data-copy'] = true;
}
});
};
}
Custom Plugin with Options
A reading time calculator that attaches data to each file:
// plugins/remark-reading-time.js
import { toString } from 'mdast-util-to-string';
export function remarkReadingTime(options = {}) {
const { wordsPerMinute = 200, emoji = true } = options;
return (tree, file) => {
const text = toString(tree);
const words = text.split(/\s+/).filter(Boolean).length;
const minutes = Math.ceil(words / wordsPerMinute);
const readingTime = emoji
? `${minutes} min read ☕`
: `${minutes} min read`;
// Attach to file data for use in layouts
file.data.readingTime = readingTime;
file.data.wordCount = words;
// Also expose via frontmatter
if (!file.data.matter) file.data.matter = {};
file.data.matter.readingTime = readingTime;
file.data.matter.wordCount = words;
};
}
Plugin Configuration
Full Configuration Example
Here's a real-world config with everything wired up:
// mdx.config.js - Complete plugin setup
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import remarkToc from 'remark-toc';
import remarkSlug from 'remark-slug';
import remarkFrontmatter from 'remark-frontmatter';
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
import rehypeKatex from 'rehype-katex';
import rehypeHighlight from 'rehype-highlight';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
import { remarkCallouts } from './plugins/remark-callouts';
import { remarkReadingTime } from './plugins/remark-reading-time';
import { rehypeCodeMeta } from './plugins/rehype-code-meta';
export const mdxOptions = {
remarkPlugins: [
remarkFrontmatter,
remarkMdxFrontmatter,
remarkGfm,
remarkSlug,
remarkMath,
[remarkToc, { heading: 'Contents', maxDepth: 3 }],
remarkCallouts,
[remarkReadingTime, { wordsPerMinute: 220 }],
],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
rehypeKatex,
[rehypeHighlight, { ignoreMissing: true }],
rehypeCodeMeta,
],
};
Framework-Specific Configuration
// next.config.mjs (Next.js)
import createMDX from '@next/mdx';
import { mdxOptions } from './mdx.config.js';
const withMDX = createMDX({
options: mdxOptions,
});
export default withMDX({
pageExtensions: ['js', 'jsx', 'md', 'mdx'],
});
// astro.config.mjs (Astro)
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import { mdxOptions } from './mdx.config.js';
export default defineConfig({
integrations: [mdx()],
markdown: {
remarkPlugins: mdxOptions.remarkPlugins,
rehypePlugins: mdxOptions.rehypePlugins,
},
});
Plugin Order Matters
This is where people get tripped up. Plugins run in order, and some depend on earlier plugins having already done their work.
// ✅ CORRECT ORDER
rehypePlugins: [
rehypeSlug, // 1. First: add IDs to headings
rehypeAutolinkHeadings, // 2. Then: add links (needs IDs from step 1)
rehypeHighlight, // 3. After: highlight code blocks
rehypeCodeMeta, // 4. Last: process meta attributes
]
// ❌ WRONG ORDER - autolink won't find heading IDs
rehypePlugins: [
rehypeAutolinkHeadings, // Runs first but headings have no IDs yet!
rehypeSlug, // IDs are added too late
]
Dependency Chain
| remarkFrontmatter | remarkMdxFrontmatter (needs parsed frontmatter) |
| remarkSlug | remarkToc (TOC needs heading slugs) |
| rehypeSlug | rehypeAutolinkHeadings (links need heading IDs) |
| remarkMath | rehypeKatex (katex renders parsed math nodes) |
If something isn't working, check your plugin order first. It's almost always that.
Performance Considerations
| Plugin | Build Impact | Recommendation |
|---|
| remark-gfm | Minimal (~5ms) | Always include |
| remark-math + rehype-katex | Moderate (~50ms) | Only if using math |
| rehype-highlight | High (~100ms/file) | Consider Shiki for large sites |
| rehype-prism-plus | Moderate (~60ms/file) | Good balance of speed/features |
| Custom plugins | Varies | Profile with console.time() |
Tip: For sites with 100+ MDX pages, cache the compiled output. Each plugin adds processing time per file during build. Profile your pipeline with unified().use(reporter) to find bottlenecks.
// Profile plugin execution time
function timedPlugin(plugin, name) {
return (...args) => {
const transformer = plugin(...args);
return (tree, file) => {
const start = performance.now();
const result = transformer(tree, file);
const elapsed = performance.now() - start;
console.log(`[${name}] ${file.path}: ${elapsed.toFixed(2)}ms`);
return result;
};
};
}
// Usage
remarkPlugins: [
timedPlugin(remarkGfm, 'gfm'),
timedPlugin(remarkMath, 'math'),
]
Plugins are what make MDX truly powerful. Combine community plugins with your own custom transformers, and you can build a content pipeline that handles anything — math, callouts, reading time, code meta — all processed at build time with zero runtime cost.