Rendering Math in Astro MDX with KaTeX: Two Problems and How to Fix Them
Getting KaTeX math to render correctly in Astro MDX is not as simple as adding remark-math and rehype-katex. Two distinct problems appear: math that renders as raw text due to missing CSS, and display math that centers itself against your layout. Here is what caused each one and how to fix both.
This post is a draft. The workaround described here is confirmed working but the root cause has not been fully traced upstream. A standalone GitHub reproduction is planned before filing an issue. A standalone GitHub reproduction is planned to isolate the root cause before filing an issue against @astrojs/mdx.
I was adding a series of mathematics posts to this blog (gradient descent, entropy, SVMs) and the LaTeX was not rendering. After fixing that, the display math blocks were centering themselves despite my layout being left-aligned. Neither problem had a single obvious cause. This post documents what was happening in each case and the exact steps used to diagnose and fix both.
The Setup
The standard approach for math in Astro is two packages:
npm install remark-math rehype-katex
Versions used in this post:
| Package | Version |
|---|---|
| astro | 6.3.7 |
| @astrojs/mdx | 5.0.6 |
| remark-math | 6.0.0 |
| rehype-katex | 7.0.1 |
| unist-util-visit | 5.1.0 |
For plain .md files, this works out of the box:
// astro.config.mjs
markdown: {
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
}
For .mdx files, you need to add the plugins explicitly to the MDX integration and set extendMarkdownConfig: false, otherwise the MDX pipeline does not inherit them:
mdx({
extendMarkdownConfig: false,
remarkPlugins: [remarkGfm, remarkMath],
rehypePlugins: [rehypeKatex],
}),
With this in place, $x^2$ and $$\nabla f$$ should render. They did not.
Problem 1: Math Rendering as Raw Text or Doubling
The first symptom was math rendering as raw LaTeX inside what looked like code spans, for example h(x) = \text{sign}(w^T x), or doubling, where the rendered math appeared alongside the raw source text side by side.
The doubling is the diagnostic clue. KaTeX produces two parallel representations: a visual .katex-html element and a .katex-mathml element for screen readers. The MathML element is hidden by the KaTeX stylesheet via clip-path: inset(50%). When both are visible, the CSS is not loading.
To confirm, check the rendered HTML directly:
curl -s http://localhost:4321/your-post-slug | grep -o 'katex[^"]*"' | sort -u | head -10
If .katex-mathml appears alongside .katex-html in the visible output, the stylesheet is missing. If no .katex classes appear at all, the plugins are not running.
To verify the plugins are running correctly, test KaTeX in isolation:
node -e "
import('katex').then(m =>
console.log(m.default.renderToString('x^2'))
).catch(e => console.log('katex missing:', e.message))
"
The fix is to load the KaTeX stylesheet. The CDN approach is fragile: integrity hash mismatches or network failures silently drop the stylesheet. Copy the CSS and fonts from the installed package instead:
cp node_modules/katex/dist/katex.min.css public/katex.min.css
cp -r node_modules/katex/dist/fonts public/fonts
Then reference it in your base layout:
<link rel="stylesheet" href="/katex.min.css" />
Alternatively, import it directly from node_modules in your layout’s frontmatter. Vite will bundle it and resolve the font paths automatically:
---
import 'katex/dist/katex.min.css';
---
The Vite import is cleaner: no manual file copying, no font management, and the fonts are served from hashed asset paths rather than a fixed /fonts/ directory.
Problem 2: Display Math Blocks Centering Themselves
After the CSS was loading, display math ($$...$$) was rendering correctly in most posts but was centered in others, ignoring the left-aligned layout.
The diagnosis required checking the actual HTML output:
curl -s http://localhost:4321/your-post-slug | python3 -c "
import sys, re
html = sys.stdin.read()
idx = html.find('<body')
body = html[idx:]
divs = re.findall(r'<div class=\"math-display\"', body)
displays = re.findall(r'<span class=\"katex-display\"', body)
print(f'math-display divs: {len(divs)}, katex-display spans: {len(displays)}')
"
Posts with centered math had bare <span class="katex-display"> elements. Posts that rendered correctly had none. Their display math was wrapped in a <div class="math-display"> instead.
The difference came from how $$...$$ was written in the source:
<!-- Multiline: remark-math emits a block `math` node -->
<!-- rehype-katex processes it as display math → produces .katex-display -->
$$
\nabla f(x, y) = \begin{bmatrix} f_x \\ f_y \end{bmatrix}
$$
<!-- Single-line: treated differently by the MDX pipeline -->
<!-- No .katex-display produced -->
$$\nabla f(x, y) = \begin{bmatrix} f_x \\ f_y \end{bmatrix}$$
KaTeX’s own stylesheet sets .katex-display { text-align: center }. When the element exists in the HTML, that rule applies and overrides the layout. When it does not exist (single-line path), the math is just an inline span and inherits text alignment from the parent.
To confirm what parent element wraps the math:
curl -s http://localhost:4321/your-post-slug | python3 -c "
import sys, re
html = sys.stdin.read()
idx = html.find('<body')
body = html[idx:]
i = body.find('katex-display')
print(repr(body[max(0, i-100):i+30]))
"
The output showed <span class="katex-display"> as a direct child of the section, with no wrapping element. The fix was a small rehype plugin that runs after rehype-katex and wraps any bare .katex-display spans in a <div class="math-display">:
// scripts/rehype-math-display.mjs
import { visit, SKIP } from 'unist-util-visit';
export default function rehypeMathDisplay() {
return (tree) => {
// Wrap bare <span class="katex-display"> in a styled div
visit(tree, 'element', (node, index, parent) => {
if (
node.tagName !== 'span' ||
!node.properties?.className?.includes('katex-display')
) return;
if (!parent || index == null) return;
if (parent.properties?.className?.includes('math-display')) return;
const wrapper = {
type: 'element',
tagName: 'div',
properties: { className: ['math-display'] },
children: [node],
};
parent.children.splice(index, 1, wrapper);
return [SKIP, index + 1];
});
// Also handle <p> whose only child is an inline <span class="katex">
visit(tree, 'element', (node) => {
if (node.tagName !== 'p') return;
const meaningful = node.children.filter(
(c) => !(c.type === 'text' && c.value.trim() === '')
);
if (meaningful.length !== 1) return;
const child = meaningful[0];
if (
child.type !== 'element' ||
child.tagName !== 'span' ||
!child.properties?.className?.includes('katex')
) return;
node.tagName = 'div';
node.properties = { className: ['math-display'] };
});
};
}
The plugin uses unist-util-visit which is already a dependency of the unified ecosystem but install it explicitly to be safe:
npm install unist-util-visit
Register it after rehype-katex in the MDX config:
import rehypeMathDisplay from './scripts/rehype-math-display.mjs';
mdx({
extendMarkdownConfig: false,
remarkPlugins: [remarkGfm, remarkMath],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
rehypeKatex,
rehypeMathDisplay, // must come after rehype-katex
],
}),
Then in your prose CSS, style both display and inline math. For inline math ($x^2$ inside a sentence) apply a pill style matching your inline code:
/* Inline math — pill style matching inline code */
/* Exclude table cells and .math-display to avoid double-styling */
.prose p:not(.math-display) > .katex,
.prose li > .katex,
.prose strong > .katex {
display: inline-flex;
align-items: center;
background-color: var(--bg-subtle);
padding: 0.1em 0.35em;
border: 1px solid var(--border);
vertical-align: middle;
margin: 0 0.15em;
}
/* Display math block */
.prose .math-display {
background-color: var(--bg-subtle);
border: 1px solid var(--border);
padding: 1em 1.25em;
margin: 0.75em 0;
overflow-x: auto;
text-align: left;
}
/* Override KaTeX's built-in text-align: center on .katex-display */
/* !important is required — KaTeX's CSS loads after prose CSS via Vite and wins the cascade */
.prose .math-display .katex-display,
.prose .math-display .katex-display > .katex,
.prose .math-display .katex-display > .katex > .katex-html {
text-align: left !important;
margin: 0 !important;
}
Debugging Toolkit
These one-liners were useful throughout:
# Check what katex class names appear in the rendered HTML
curl -s http://localhost:4321/your-post | grep -o 'katex[^"]*"' | sort -u
# Count math-display divs vs bare katex-display spans
curl -s http://localhost:4321/your-post | python3 -c "
import sys, re
body = sys.stdin.read()
print('math-display:', len(re.findall(r'class=\"math-display\"', body)))
print('katex-display:', len(re.findall(r'class=\"katex-display\"', body)))
"
# Inspect the parent element of a katex-display span
curl -s http://localhost:4321/your-post | python3 -c "
import sys
body = sys.stdin.read()
i = body.find('katex-display')
print(repr(body[max(0, i-100):i+30]))
"
# Verify katex is installed and rendering correctly
node -e "
import('katex').then(m =>
console.log(m.default.renderToString('x^2'))
)"
# Check which font formats the katex CSS references
grep -o '\.woff2\|\.woff\|\.ttf' public/katex.min.css | sort | uniq -c
What You Can Do Now
The configuration below is the complete working setup for an Astro project with math in MDX files.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import rehypeMathDisplay from './scripts/rehype-math-display.mjs';
export default defineConfig({
integrations: [
mdx({
extendMarkdownConfig: false,
remarkPlugins: [remarkGfm, remarkMath],
rehypePlugins: [
rehypeKatex,
rehypeMathDisplay,
],
}),
],
markdown: {
remarkPlugins: [remarkGfm, remarkMath],
rehypePlugins: [rehypeKatex],
},
});
Add to your base layout frontmatter:
---
import 'katex/dist/katex.min.css';
---
Copy scripts/rehype-math-display.mjs from the listing above. With these three pieces in place, both $inline$ and $$display$$ math render correctly in .mdx files, left-aligned, with no doubling.