Set up code syntax highlighting in MDX with Prism.js, Shiki, or highlight.js — plus themes, line highlighting, dark mode, and performance tips.
Good docs need good-looking code. Syntax highlighting makes code scannable — your readers spot keywords, strings, and structure at a glance instead of squinting at a wall of monospace text.
MDX supports three major highlighting engines. Each has different tradeoffs. Let's look at all of them, plus the advanced stuff: line highlighting, dark mode switching, and custom themes.
Highlighting Engine Comparison
| Feature | Prism.js | Shiki | highlight.js |
|---|
| Approach | Runtime (client) | Build-time (server) | Runtime or build-time |
| Accuracy | Good | Excellent (TextMate) | Good |
| Themes | CSS-based | VS Code themes | CSS-based |
| Bundle Size | ~15KB + languages | 0KB (pre-rendered) | ~30KB + languages |
| Performance | Fast client-side | Zero runtime cost | Moderate |
| Language Support | 290+ languages | 180+ languages | 190+ languages |
| Best For | Interactive sites | Static docs | Legacy projects |
Quick decision guide:
- Static docs site? → Shiki. Zero client JS, perfect accuracy.
- Interactive playground? → Prism. It's fast and extensible.
- Legacy project, need lots of languages? → highlight.js. Minimal config.
Prism.js Integration
Prism is the most popular client-side highlighter. It's got a plugin ecosystem and CSS-based theming.
Setup with prism-react-renderer
npm install prism-react-renderer
// components/CodeBlock.jsx
import React from 'react';
import { Highlight, themes } from 'prism-react-renderer';
export default function CodeBlock({ children, className, metastring }) {
const language = className?.replace('language-', '') || 'text';
const code = children?.trim() || '';
// Parse meta string for options
const title = metastring?.match(/title="([^"]+)"/)?.[1];
const highlightLines = parseHighlightLines(metastring);
const showLineNumbers = metastring?.includes('showLineNumbers');
return (
<div className="code-block-wrapper relative group">
{title && (
<div className="code-title px-4 py-2 bg-gray-800 text-gray-300 text-sm font-mono rounded-t-lg border-b border-gray-700">
{title}
</div>
)}
<Highlight
theme={themes.nightOwl}
code={code}
language={language}
>
{({ className: hlClassName, style, tokens, getLineProps, getTokenProps }) => (
<pre
className={`${hlClassName} ${title ? 'rounded-t-none' : ''} overflow-x-auto p-4 rounded-lg`}
style={style}
>
<code>
{tokens.map((line, i) => {
const lineProps = getLineProps({ line, key: i });
const isHighlighted = highlightLines.includes(i + 1);
return (
<div
key={i}
{...lineProps}
className={`${lineProps.className} ${
isHighlighted ? 'bg-yellow-900/30 border-l-2 border-yellow-400 -ml-4 pl-4 pr-4 -mr-4' : ''
}`}
>
{showLineNumbers && (
<span className="inline-block w-8 text-gray-500 text-right mr-4 select-none">
{i + 1}
</span>
)}
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token, key })} />
))}
</div>
);
})}
</code>
</pre>
)}
</Highlight>
<CopyButton code={code} />
</div>
);
}
function parseHighlightLines(meta) {
if (!meta) return [];
const match = meta.match(/\{([^}]+)\}/);
if (!match) return [];
const ranges = match[1].split(',');
const lines = [];
ranges.forEach(range => {
if (range.includes('-')) {
const [start, end] = range.split('-').map(Number);
for (let i = start; i <= end; i++) lines.push(i);
} else {
lines.push(Number(range));
}
});
return lines;
}
function CopyButton({ code }) {
const [copied, setCopied] = React.useState(false);
return (
<button
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs"
onClick={() => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? '✓ Copied' : 'Copy'}
</button>
);
}
Registering with MDXProvider
Wire up the code block so MDX uses it for all fenced code:
// components/MDXComponents.jsx
import CodeBlock from './CodeBlock';
export const mdxComponents = {
pre: ({ children }) => <>{children}</>,
code: ({ children, className, ...props }) => {
// Inline code (no className) vs block code
if (!className) {
return (
<code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono">
{children}
</code>
);
}
return <CodeBlock className={className} {...props}>{children}</CodeBlock>;
},
};
Shiki Integration
Shiki uses VS Code's TextMate grammars. The highlighting is the most accurate you'll get — and it runs at build time, so your users download zero highlighting JS.
Setup with rehype-shiki
npm install shiki @shikijs/rehype
// mdx.config.js
import rehypeShiki from '@shikijs/rehype';
export default {
rehypePlugins: [
[rehypeShiki, {
theme: 'one-dark-pro',
// Or use multiple themes for light/dark mode:
themes: {
light: 'github-light',
dark: 'one-dark-pro',
},
// Add language support
langs: ['javascript', 'typescript', 'jsx', 'tsx', 'css', 'html', 'json', 'bash', 'python', 'rust', 'go'],
// Transformers for line highlighting etc.
transformers: [
// Add later - see transformers section
],
}],
],
};
These let you highlight lines, show diffs, and focus specific code — all with inline comments in your code blocks:
// shiki-transformers.js
import {
transformerNotationHighlight,
transformerNotationDiff,
transformerNotationFocus,
transformerMetaHighlight,
} from '@shikijs/transformers';
export const shikiTransformers = [
// Highlight lines with comments: // [!code highlight]
transformerNotationHighlight(),
// Diff notation: // [!code ++] and // [!code --]
transformerNotationDiff(),
// Focus lines: // [!code focus]
transformerNotationFocus(),
// Highlight via meta string: ```js {1,3-5}
transformerMetaHighlight(),
];
Here's how it looks in practice:
function greet(name) { console.log(Hello, ${name}!); // [!code highlight] return name; }
// Diff example: function calculate(a, b) { return a - b; // [!code --] return a + b; // [!code ++] }
Dual Theme (Light/Dark Mode)
Shiki can render both themes into the same HTML. CSS picks which one to show:
// Shiki dual theme configuration
import rehypeShiki from '@shikijs/rehype';
export default {
rehypePlugins: [
[rehypeShiki, {
themes: {
light: 'github-light',
dark: 'github-dark',
},
defaultColor: false, // Let CSS handle which theme shows
}],
],
};
/* styles/shiki-themes.css */
/* Light mode - show light theme colors */
html:not(.dark) .shiki,
html:not(.dark) .shiki span {
color: var(--shiki-light) !important;
background-color: var(--shiki-light-bg) !important;
}
/* Dark mode - show dark theme colors */
html.dark .shiki,
html.dark .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
highlight.js Integration
The veteran option. Broad language support, minimal setup. Good for projects that just need things to work without fuss.
Setup with rehype-highlight
npm install rehype-highlight highlight.js
import rehypeHighlight from 'rehype-highlight';
export default {
rehypePlugins: [
[rehypeHighlight, {
ignoreMissing: true,
aliases: { javascript: ['js'], typescript: ['ts'] },
languages: {
hcl: require('highlight.js/lib/languages/hcl'),
nginx: require('highlight.js/lib/languages/nginx'),
},
}],
],
};
/* Import a highlight.js theme */
@import 'highlight.js/styles/github-dark.css';
/* Or conditionally for dark/light mode */
@media (prefers-color-scheme: light) {
@import 'highlight.js/styles/github.css';
}
@media (prefers-color-scheme: dark) {
@import 'highlight.js/styles/github-dark.css';
}
Theme Customization
Custom Prism Theme
Build your own color scheme by defining token styles:
// themes/custom-prism-theme.js
export const customTheme = {
plain: {
color: '#e4e4e7',
backgroundColor: '#18181b',
},
styles: [
{
types: ['comment', 'prolog', 'doctype', 'cdata'],
style: { color: '#6b7280', fontStyle: 'italic' },
},
{
types: ['keyword', 'tag', 'operator'],
style: { color: '#c084fc' },
},
{
types: ['string', 'attr-value', 'template-string'],
style: { color: '#34d399' },
},
{
types: ['function', 'class-name'],
style: { color: '#60a5fa' },
},
{
types: ['number', 'boolean'],
style: { color: '#f59e0b' },
},
{
types: ['variable', 'constant'],
style: { color: '#f87171' },
},
{
types: ['punctuation'],
style: { color: '#9ca3af' },
},
],
};
Custom Shiki Theme
Shiki uses the VS Code theme format, so you can literally copy a theme from VS Code:
// themes/my-custom-theme.json (VS Code theme format)
{
"name": "My Custom Theme",
"type": "dark",
"colors": {
"editor.background": "#1a1b26",
"editor.foreground": "#a9b1d6"
},
"tokenColors": [
{
"scope": ["comment"],
"settings": { "foreground": "#565f89", "fontStyle": "italic" }
},
{
"scope": ["keyword", "storage.type"],
"settings": { "foreground": "#bb9af7" }
},
{
"scope": ["string"],
"settings": { "foreground": "#9ece6a" }
},
{
"scope": ["entity.name.function"],
"settings": { "foreground": "#7aa2f7" }
}
]
}
// Load custom theme
import { createHighlighter } from 'shiki';
import customTheme from './themes/my-custom-theme.json';
const highlighter = await createHighlighter({
themes: [customTheme],
langs: ['javascript', 'typescript'],
});
Line Highlighting
You can highlight specific lines by passing line numbers in the meta string (the stuff after the language name in your code fence):
import React from 'react';
export function Button({ children, onClick }) { // This line is highlighted return ( <button onClick={onClick}> {/* Highlighted */} {children} {/* Highlighted */} </button> {/* Highlighted */} ); }
Word/Token Highlighting
Want to highlight specific words within a code block? Here's how:
// components/CodeBlock.jsx - Word highlighting support
function highlightWords(code, words) {
if (!words || words.length === 0) return code;
let result = code;
words.forEach(word => {
const regex = new RegExp(`(${escapeRegex(word)})`, 'g');
result = result.replace(
regex,
'<mark class="code-word-highlight">$1</mark>'
);
});
return result;
}
/* Word highlight styles */
.code-word-highlight {
background-color: rgba(251, 191, 36, 0.2);
border-bottom: 2px solid #f59e0b;
border-radius: 2px;
padding: 1px 2px;
}
Custom Language Grammars
Got a custom DSL or config format? You can teach Shiki to highlight it:
// Register a custom language for Shiki
const customLang = {
id: 'myDSL',
scopeName: 'source.mydsl',
grammar: {
patterns: [
{ match: '//.*$', name: 'comment.line.mydsl' },
{ match: '\\b(define|when|then|end)\\b', name: 'keyword.control.mydsl' },
{ match: '"[^"]*"', name: 'string.quoted.double.mydsl' },
{ match: '\\b\\d+\\b', name: 'constant.numeric.mydsl' },
{ match: '\\b[A-Z][a-zA-Z]*\\b', name: 'entity.name.type.mydsl' },
],
},
};
const highlighter = await createHighlighter({
themes: ['one-dark-pro'],
langs: [...builtinLanguages, customLang],
});
Light/Dark Mode Support
Complete Implementation
Here's a Prism-based code block that reacts to the user's color scheme:
// components/ThemedCodeBlock.jsx
import React, { useState, useEffect } from 'react';
import { Highlight, themes } from 'prism-react-renderer';
export default function ThemedCodeBlock({ children, className }) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// Listen for theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
setIsDark(mediaQuery.matches);
const handler = (e) => setIsDark(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
const theme = isDark ? themes.nightOwl : themes.github;
const language = className?.replace('language-', '') || 'text';
return (
<Highlight theme={theme} code={children.trim()} language={language}>
{({ className: hlClass, style, tokens, getLineProps, getTokenProps }) => (
<pre className={`${hlClass} rounded-lg p-4 overflow-x-auto transition-colors duration-200`} style={style}>
<code>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</code>
</pre>
)}
</Highlight>
);
}
| Approach | Build Impact | Runtime Impact | Recommendation |
|---|
| Shiki (build-time) | +2-5s total build | 0ms (pre-rendered HTML) | Best for static sites |
| Prism (client-side) | None | ~15-50ms per block | Best for interactive/SPA |
| highlight.js (rehype) | +1-3s total build | 0ms (pre-rendered) | Good balance |
| Shiki (on-demand) | None | ~20-80ms first render | Best for dynamic content |
Optimization Tips
// 1. Only load languages you actually use
const highlighter = await createHighlighter({
themes: ['one-dark-pro'],
langs: ['js', 'ts', 'jsx', 'css', 'bash'], // NOT all 180+ languages
});
// 2. Cache the highlighter instance
let cachedHighlighter = null;
export async function getHighlighter() {
if (!cachedHighlighter) {
cachedHighlighter = await createHighlighter({ /* ... */ });
}
return cachedHighlighter;
}
// 3. Lazy load heavy languages
const highlighter = await createHighlighter({
themes: ['one-dark-pro'],
langs: ['javascript', 'typescript'], // Load common ones
});
// Load on demand when needed
await highlighter.loadLanguage('rust');
await highlighter.loadLanguage('haskell');
Performance tip: For docs sites with 100+ code blocks, Shiki's build-time approach eliminates all runtime JS for highlighting. That's 50-100KB off your client bundle and no layout shifts from async highlighting. The tradeoff? Slightly longer builds (~50ms per code block). Worth it.
Pick your engine based on what you're building: Shiki for static docs, Prism for interactive playgrounds, highlight.js for broad language support with minimal config.