Your MDX site started fast. Ten pages, quick builds, snappy loads. Then it grew to hundreds of pages, and now builds take forever, bundles balloon, and users wait. Let's fix that.
We'll cover every layer — compilation, bundling, runtime rendering, and caching.
MDX Compilation Optimization
Understanding the Compilation Pipeline
Every MDX file goes through this chain:
!MDX Plugin Pipeline
Each step takes time. You can't optimize what you can't measure, so let's start there.
Measuring Build Performance
// scripts/measure-build.js
import { compile } from '@mdx-js/mdx';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
async function measureCompilation(dir) {
const files = await readdir(dir, { recursive: true });
const mdxFiles = files.filter(f => f.endsWith('.mdx'));
console.log(`\nCompiling ${mdxFiles.length} MDX files...\n`);
const results = [];
const totalStart = performance.now();
for (const file of mdxFiles) {
const content = await readFile(join(dir, file), 'utf-8');
const start = performance.now();
await compile(content, {
remarkPlugins: [/* your plugins */],
rehypePlugins: [/* your plugins */],
});
const elapsed = performance.now() - start;
results.push({ file, elapsed, size: content.length });
}
const totalElapsed = performance.now() - totalStart;
// Report
results.sort((a, b) => b.elapsed - a.elapsed);
console.log('=== Slowest Files ===');
results.slice(0, 10).forEach(({ file, elapsed, size }) => {
console.log(` ${elapsed.toFixed(0)}ms | ${(size / 1024).toFixed(1)}KB | ${file}`);
});
console.log(`\n=== Summary ===`);
console.log(` Total: ${totalElapsed.toFixed(0)}ms`);
console.log(` Average: ${(totalElapsed / mdxFiles.length).toFixed(0)}ms per file`);
console.log(` Files: ${mdxFiles.length}`);
}
measureCompilation('./content');
Optimizing Plugin Configuration
The biggest easy win: stop loading plugins you don't need for every file.
If only 5% of your pages have math, why is every file paying the cost of remarkMath?
// ❌ SLOW: Loading unnecessary plugins for every file
export const slowConfig = {
remarkPlugins: [
remarkGfm,
remarkMath, // Only 5% of pages use math
remarkMermaid, // Only 3% of pages use diagrams
remarkEmbedder, // Only 10% of pages embed content
remarkReadingTime,
remarkToc,
],
rehypePlugins: [
rehypeSlug,
rehypeAutolinkHeadings,
rehypeKatex, // Only needed if remarkMath found math
rehypeMermaid, // Only needed if remarkMermaid found diagrams
rehypeHighlight,
rehypeImageSize,
],
};
// ✅ FAST: Conditional plugin loading based on content
export function getOptimizedConfig(source, frontmatter) {
const remarkPlugins = [
remarkGfm,
remarkReadingTime,
];
const rehypePlugins = [
rehypeSlug,
rehypeAutolinkHeadings,
rehypeHighlight,
];
// Only add math plugins if content contains math
if (source.includes('$') || source.includes('\\[')) {
remarkPlugins.push(remarkMath);
rehypePlugins.push(rehypeKatex);
}
// Only add mermaid if content has mermaid blocks
if (source.includes('```mermaid')) {
remarkPlugins.push(remarkMermaid);
}
// Only add TOC plugin if frontmatter requests it
if (frontmatter?.toc !== false) {
remarkPlugins.push([remarkToc, { maxDepth: 3 }]);
}
return { remarkPlugins, rehypePlugins };
}
The difference is real:
| Approach | 500 files build time | Per-file average |
|---|
| All plugins always | 45s | 90ms |
| Conditional plugins | 28s | 56ms |
| Improvement | 38% faster | 38% faster |
Caching Strategies
File-Based Compilation Cache
Why recompile a file that hasn't changed? Hash the source + config, and skip compilation on cache hits.
// lib/mdx-cache.js
import { compile } from '@mdx-js/mdx';
import { createHash } from 'crypto';
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
const CACHE_DIR = '.cache/mdx';
function getCacheKey(source, options) {
const hash = createHash('sha256');
hash.update(source);
hash.update(JSON.stringify(options));
hash.update(process.env.MDX_CACHE_VERSION || '1');
return hash.digest('hex');
}
export async function compileMDXCached(source, options = {}) {
const cacheKey = getCacheKey(source, options);
const cachePath = join(CACHE_DIR, `${cacheKey}.json`);
// Try cache hit
try {
const cached = JSON.parse(await readFile(cachePath, 'utf-8'));
console.log(`[MDX Cache] HIT: ${cacheKey.slice(0, 8)}`);
return cached;
} catch {
// Cache miss - compile fresh
}
console.log(`[MDX Cache] MISS: ${cacheKey.slice(0, 8)}`);
const start = performance.now();
const result = await compile(source, options);
const elapsed = performance.now() - start;
// Write to cache
const output = {
code: String(result),
compiledAt: Date.now(),
compileTime: elapsed,
};
await mkdir(CACHE_DIR, { recursive: true });
await writeFile(cachePath, JSON.stringify(output));
return output;
}
Incremental Compilation
In dev mode, only recompile the file that changed:
// lib/incremental-build.js
import { watch } from 'chokidar';
import { compileMDXCached } from './mdx-cache';
const compiledPages = new Map();
export function startIncrementalBuild(contentDir) {
const watcher = watch(`${contentDir}/**/*.mdx`);
watcher.on('change', async (filePath) => {
console.log(`[Incremental] Recompiling: ${filePath}`);
const start = performance.now();
const source = await readFile(filePath, 'utf-8');
const result = await compileMDXCached(source, getConfig(source));
compiledPages.set(filePath, result);
console.log(`[Incremental] Done: ${(performance.now() - start).toFixed(0)}ms`);
});
watcher.on('unlink', (filePath) => {
compiledPages.delete(filePath);
});
return { compiledPages, close: () => watcher.close() };
}
Build-Time Cache with Content Hashing
Webpack's filesystem cache works great with MDX:
// next.config.js - Webpack caching for MDX
module.exports = {
webpack: (config, { isServer }) => {
// Enable persistent caching
config.cache = {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
// Invalidate cache when MDX config changes
version: `mdx-${process.env.MDX_CONFIG_HASH || 'v1'}`,
};
return config;
},
};
Lazy Loading Components
Your users don't need to download a 150KB charting library before they even scroll to the chart. Load heavy stuff on demand.
Dynamic Imports for Heavy Components
// components/LazyComponents.jsx
import dynamic from 'next/dynamic';
import React, { Suspense } from 'react';
// Heavy components loaded on demand
export const Chart = dynamic(() => import('./Chart'), {
loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded-lg" />,
ssr: false, // Client-only for interactive charts
});
export const Playground = dynamic(() => import('./Playground'), {
loading: () => (
<div className="h-96 border rounded-xl flex items-center justify-center bg-gray-50">
<span className="text-gray-400">Loading playground...</span>
</div>
),
});
export const MermaidDiagram = dynamic(() => import('./MermaidDiagram'), {
loading: () => <div className="h-48 bg-gray-100 animate-pulse rounded-lg" />,
ssr: false,
});
export const VideoPlayer = dynamic(() => import('./VideoPlayer'), {
loading: () => <div className="aspect-video bg-black rounded-lg" />,
ssr: false,
});
Intersection Observer for Below-the-Fold Content
Don't even start loading until the user scrolls close:
// components/LazyLoad.jsx
import React, { useRef, useState, useEffect } from 'react';
export function LazyLoad({ children, height = 200, threshold = 0.1 }) {
const ref = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold, rootMargin: '200px' } // Start loading 200px before visible
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [threshold]);
return (
<div ref={ref} style={{ minHeight: isVisible ? 'auto' : height }}>
{isVisible ? children : (
<div
className="bg-gray-100 dark:bg-gray-800 rounded-lg animate-pulse"
style={{ height }}
/>
)}
</div>
);
}
Then in your MDX:
import { LazyLoad } from '../components/LazyLoad';
import { Chart } from '../components/LazyComponents';
# Analytics Dashboard
Here's our performance data:
<LazyLoad height={400}>
<Chart
data={performanceData}
type="line"
options={{ animation: true }}
/>
</LazyLoad>
Code Splitting in MDX
Per-Page Component Bundles
Register lightweight components globally and code-split the heavy ones:
// mdx-components.jsx - Smart component loading
import dynamic from 'next/dynamic';
export function useMDXComponents(components) {
return {
// Lightweight components - always included
Callout: (props) => import('./components/Callout').then(m => m.default(props)),
Badge: (props) => import('./components/Badge').then(m => m.default(props)),
// Heavy components - code split
Playground: dynamic(() => import('./components/Playground')),
Chart: dynamic(() => import('./components/Chart'), { ssr: false }),
Sandbox: dynamic(() => import('./components/Sandbox'), { ssr: false }),
VideoEmbed: dynamic(() => import('./components/VideoEmbed'), { ssr: false }),
...components,
};
}
Bundle Analysis
Want to know what's actually making your bundle fat? Run the analyzer:
# Analyze your MDX bundle
npx @next/bundle-analyzer
# Or use webpack-bundle-analyzer directly
npx webpack-bundle-analyzer .next/analyze/client.html
// next.config.js - Bundle analyzer setup
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your config
});
Typical MDX bundle offenders:
| Module | Size (gzipped) | Action |
|---|
| react-live | 45KB | Lazy load |
| prism-react-renderer | 15KB | Tree-shake languages |
| katex | 90KB | Only load on math pages |
| mermaid | 150KB | Always lazy load |
| shiki (all languages) | 2MB | Load only needed langs |
| highlight.js (all) | 300KB | Register subset |
Image Optimization
Responsive Images in MDX
Images are often the largest assets on a page. Use Next.js Image (or your framework's equivalent) for automatic optimization:
// components/OptimizedImage.jsx
import Image from 'next/image';
export function OptimizedImage({ src, alt, width, height, caption, priority = false }) {
return (
<figure className="my-8">
<Image
src={src}
alt={alt}
width={width || 800}
height={height || 450}
className="rounded-lg shadow-md"
placeholder="blur"
blurDataURL={`data:image/svg+xml;base64,...`}
priority={priority}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 800px"
/>
{caption && (
<figcaption className="text-center text-sm text-gray-500 mt-2">
{caption}
</figcaption>
)}
</figure>
);
}
Automatic Image Processing via Rehype
This plugin automatically adds width, height, loading="lazy", and responsive srcset to every local image in your MDX:
// plugins/rehype-optimized-images.js
import { visit } from 'unist-util-visit';
import sizeOf from 'image-size';
import { join } from 'path';
export function rehypeOptimizedImages(options = {}) {
const { publicDir = 'public', quality = 80 } = options;
return (tree) => {
visit(tree, 'element', (node) => {
if (node.tagName !== 'img') return;
const src = node.properties.src;
if (!src || src.startsWith('http')) return;
// Get image dimensions for layout stability
try {
const imagePath = join(publicDir, src);
const dimensions = sizeOf(imagePath);
node.properties.width = dimensions.width;
node.properties.height = dimensions.height;
node.properties.loading = 'lazy';
node.properties.decoding = 'async';
// Add srcset for responsive images
const widths = [400, 800, 1200];
const srcset = widths
.filter(w => w <= dimensions.width)
.map(w => `${src}?w=${w}&q=${quality} ${w}w`)
.join(', ');
if (srcset) {
node.properties.srcSet = srcset;
node.properties.sizes = '(max-width: 768px) 100vw, 800px';
}
} catch (e) {
// Skip if image not found during build
}
});
};
}
Static Generation vs Runtime
Here's the core tradeoff:
| Aspect | Static (SSG) | Runtime (SSR/CSR) |
|---|
| Build time | Longer (compile all pages) | Minimal |
| Response time | ~0ms (pre-built) | 50-500ms (compile on request) |
| Hosting cost | CDN only ($) | Server required ($$) |
| Content freshness | Rebuild required | Always fresh |
| Best for | Docs, blogs, marketing | Dashboards, user-specific content |
| Scale | Infinite (CDN) | Limited by server capacity |
For docs? Static wins almost every time.
Static Generation Strategy
// app/docs/[...slug]/page.jsx (Next.js App Router)
import { compileMDX } from 'next-mdx-remote/rsc';
import { readFile } from 'fs/promises';
import { join } from 'path';
// Generate all pages at build time
export async function generateStaticParams() {
const slugs = await getAllDocSlugs(); // Your function
return slugs.map(slug => ({ slug: slug.split('/') }));
}
export default async function DocPage({ params }) {
const filePath = join(process.cwd(), 'content', ...params.slug) + '.mdx';
const source = await readFile(filePath, 'utf-8');
const { content, frontmatter } = await compileMDX({
source,
options: {
parseFrontmatter: true,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeSlug, rehypeHighlight],
},
},
});
return (
<DocLayout {...frontmatter}>
{content}
</DocLayout>
);
}
Hybrid Approach: ISR (Incremental Static Regeneration)
Don't want to pre-build all 5,000 pages? Build the popular ones, generate the rest on first visit:
// pages/docs/[...slug].jsx (Next.js Pages Router)
export async function getStaticProps({ params }) {
const source = await getDocContent(params.slug);
return {
props: { source, frontmatter: source.frontmatter },
revalidate: 3600, // Regenerate every hour
};
}
export async function getStaticPaths() {
const paths = await getAllDocPaths();
return {
paths: paths.slice(0, 50), // Pre-build top 50 pages
fallback: 'blocking', // Generate rest on-demand
};
}
Build Time Optimization
Parallel Compilation
You have 8 CPU cores. Use them.
// scripts/parallel-build.js
import { compile } from '@mdx-js/mdx';
import pLimit from 'p-limit';
const limit = pLimit(8); // 8 concurrent compilations (match CPU cores)
async function buildAll(files) {
const start = performance.now();
const results = await Promise.all(
files.map(file =>
limit(async () => {
const source = await readFile(file, 'utf-8');
const compiled = await compile(source, getConfig(source));
return { file, compiled };
})
)
);
console.log(`Built ${files.length} files in ${(performance.now() - start).toFixed(0)}ms`);
return results;
}
Reducing Compile Scope
In dev, skip files that haven't actually changed:
// Only recompile changed files in development
import { createHash } from 'crypto';
const hashCache = new Map();
function hasFileChanged(filePath, content) {
const hash = createHash('md5').update(content).digest('hex');
const previousHash = hashCache.get(filePath);
if (hash === previousHash) return false;
hashCache.set(filePath, hash);
return true;
}
Performance Checklist
### Before Deploying, Verify:
- [ ] **Bundle size** — Is total JS under 200KB gzipped?
- [ ] **Largest page** — Does any page exceed 500KB total?
- [ ] **Code splitting** — Are heavy components lazy-loaded?
- [ ] **Images** — Do all images have width/height and lazy loading?
- [ ] **Build time** — Is full build under 5 minutes?
- [ ] **Incremental** — Do single-file changes rebuild in < 5 seconds?
- [ ] **Cache** — Is compilation output cached between builds?
- [ ] **Plugins** — Are plugins conditionally loaded based on content?
- [ ] **Fonts** — Are code fonts subsetted and preloaded?
- [ ] **Static** — Are all possible pages statically generated?
Summary Metrics
| Optimization | Impact | Effort |
|---|
| Compilation caching | 60-80% faster rebuilds | Medium |
| Conditional plugins | 30-40% faster per file | Low |
| Lazy loading components | 50-150KB less initial JS | Low |
| Static generation | 0ms response time | Medium |
| Image optimization | 50-70% smaller images | Low |
| Parallel compilation | 3-6x faster full builds | Low |
| Bundle analysis + tree-shaking | 20-40% smaller bundles | Medium |
Golden Rule: Measure first, optimize second. Use Lighthouse, WebPageTest, and your build logs to find the actual bottlenecks. Nine times out of ten, the biggest win is switching from runtime compilation to static generation — it removes the largest latency source entirely.
Start with static generation and conditional plugins (high impact, low effort). Then layer in caching, code splitting, and lazy loading as your site grows. A well-tuned MDX site serves thousands of pages with sub-100ms loads and builds in minutes, not hours.