On this page

The MDX Math Pipeline

How LaTeX Becomes Static HTML at Build Time

15 Jan 202617 min
Slop Advisory: AI-Generated Content

This article exists to showcase page rendering capabilities using AI-generated content. None of its claims have been certified by a human to represent accurate, useful, or actionable recommendations appropriate for the individual needs of any person or agent.

The goal is straightforward: produce beautifully rendered mathematics material on your site.

Summary

This article explains how a Next.js MDX pipeline renders LaTeX at build time using unified, producing static HTML and CSS math output without client-side JavaScript.

By Clay Curry.
RenderedSource

The equation above is LaTeX source embedded in MDX. By the time your browser receives it, it has already been converted to static HTML and CSS. This article describes the compilation pipeline that makes that happen — the layers it passes through, the decisions at each stage, and how it integrates with Next.js.

The Compilation Pipeline

Every .mdx file on this site is compiled into an ES module at build time. The compilation is handled by unified, a framework for processing content as abstract syntax trees (ASTs). The pipeline has four stages: parse the Markdown into an AST (mdast), transform it with remark plugins, convert it to an HTML AST (hast), and transform it with rehype plugins. The result is a React component exported from the module.

DiagramMermaidASCII

The key insight: all of this happens at build time. When a visitor loads your page, they receive pre-rendered HTML. No client-side JavaScript is required to display the equations—only CSS to style them.

Plugin Order

The actual plugin configuration from this site's next.config.mjs declares the exact processing order:

javascript
remarkPlugins: [remarkFrontmatter, remarkGfm, remarkMath, remarkMdxFrontmatter],
rehypePlugins: [rehypePrettyCode, rehypeCodeTitle, rehypeKatex, rehypeMdxToc],

Plugin order matters. Remark plugins execute left to right: remarkFrontmatter strips YAML frontmatter before remarkGfm enables GitHub-flavored Markdown tables and autolinks, remarkMath recognizes $...$ and $$...$$ delimiters, and remarkMdxFrontmatter exports frontmatter fields from the compiled module.

Rehype plugins also execute in order: rehypePrettyCode highlights code fences, rehypeCodeTitle moves title attributes onto <pre> elements, rehypeKatex renders math nodes, and rehypeMdxToc generates a table of contents. If rehypePrettyCode ran after rehypeKatex, it could try to syntax-highlight the HTML that KaTeX produced — breaking the math output.

The MDX Module Contract

Each compiled .mdx file becomes an ES module. When the blog page loads a post, it calls:

javascript
const { default: Content, toc, ...frontmatter } = await import(`@/blog/${slug}.mdx`);

Content is a React component that renders the post body. toc is a structured table of contents generated by rehypeMdxToc. The frontmatter fields (title, subtitle, tags, publishedDate, etc.) are spread as named exports. The blog page component only knows how to render these exports — it has no knowledge of the pipeline that produced them.

Boundaries

Each layer in the pipeline knows only its own representation:

  • A remark plugin operates on mdast (Markdown AST). It does not know about HTML or React.
  • A rehype plugin operates on hast (HTML AST). It does not know about Markdown syntax.
  • rehype-katex knows how to call katex.renderToString() on math nodes in hast. It does not know about Next.js, routing, or CSS loading.
  • The blog page component knows how to render a compiled MDX module. It does not know which plugins produced it.
  • The root layout imports katex/dist/katex.min.css globally. It does not know which pages use math.

There is no runtime state for math. The AST exists transiently during the build and is discarded. The only artifact that reaches the browser is static HTML and a CSS stylesheet.

Stage 1 — Parsing: remark-math

The remark-math plugin extends the Markdown parser to recognize LaTeX delimiters:

SyntaxTypeRendered As
$E = mc^2$Inline mathFlows within text
$$\int_0^1 x^2 dx$$Display mathCentered block

$...$ and $$...$$ are the only math delimiters. These are recognized by remark-math and are not configurable without writing a custom plugin.

Given this input:

mdx
The quadratic formula is $x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}$.

The parser produces an mdast node:

json
{
  "type": "inlineMath",
  "value": "x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}"
}

For display math:

mdx
$$
\sum_{i=1}^{n} i = \frac{n(n+1)}{2}
$$

Produces:

json
{
  "type": "math",
  "value": "\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}"
}

From Markdown to HTML

When remark-rehype transforms the tree from mdast to hast, math nodes become HTML elements with special classes:

html
<!-- Inline math -->
<code class="language-math math-inline">x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}</code>
 
<!-- Display math -->
<pre><code class="language-math math-display">
\sum_{i=1}^{n} i = \frac{n(n+1)}{2}
</code></pre>

At this stage, the LaTeX source is still plain text. Math nodes survive the remark-to-rehype bridge by being converted into <code> elements with language-math classes that rehype-katex can find and replace in the next stage.

Stage 2 — Rendering: KaTeX vs MathJax

The rehype plugin is where LaTeX source becomes browser-renderable output. The two options — KaTeX and MathJax — differ in what LaTeX they accept, what HTML they produce, and what tradeoffs they impose.

The Input: Which LaTeX?

LaTeX is not a single specification. It is a macro language built on TeX, extended by hundreds of packages over four decades. Neither KaTeX nor MathJax supports all of it. They implement subsets — substantial subsets, but subsets nonetheless.

KaTeX focuses on common mathematical notation:

latex
% Greek letters
\alpha, \beta, \gamma, \Gamma, \Delta, \Omega
 
% Operators
\sum, \prod, \int, \oint, \lim, \max, \min
 
% Relations
=, \neq, <, >, \leq, \geq, \approx, \equiv
 
% Fractions and roots
\frac{a}{b}, \sqrt{x}, \sqrt[n]{x}
 
% Subscripts and superscripts
x_i, x^2, x_i^2, x_{i,j}^{n+1}
 
% Matrices (with amsmath)
\begin{pmatrix} a & b \\ c & d \end{pmatrix}
 
% Aligned equations
\begin{aligned}
  a &= b + c \\
  d &= e + f
\end{aligned}

KaTeX does not support:

latex
% No TikZ diagrams
\begin{tikzpicture}...\end{tikzpicture}
 
% No arbitrary macro definitions in some contexts
\def\foo#1{...}
 
% Limited physics package support
\dv{f}{x}  % use \frac{df}{dx} instead

For the complete list, see KaTeX's supported functions. If you need a command KaTeX doesn't support, you can rewrite using supported commands, define a custom macro, or switch to MathJax.

Custom macros in KaTeX configuration:

javascript
// Custom macros for common notation
const katexOptions = {
  macros: {
    "\\R": "\\mathbb{R}",
    "\\N": "\\mathbb{N}",
    "\\Z": "\\mathbb{Z}",
    "\\norm": "\\left\\|#1\\right\\|",
    "\\abs": "\\left|#1\\right|",
  },
};

Now you can write $x \in \R$ instead of $x \in \mathbb{R}$.

The Output: HTML+CSS vs SVG vs MathML

The renderer must produce something the browser can display. There are three approaches.

HTML+CSS (KaTeX default) — renders math as nested <span> elements with precise CSS positioning:

html
<!-- Input: $\frac{a}{b}$ -->
<span class="katex">
  <span class="katex-mathml">
    <!-- Hidden MathML for accessibility -->
    <math xmlns="http://www.w3.org/1998/Math/MathML">...</math>
  </span>
  <span class="katex-html" aria-hidden="true">
    <span class="base">
      <span class="mord">
        <span class="mopen nulldelimiter"></span>
        <span class="mfrac">
          <span class="vlist-t vlist-t2">
            <span class="vlist-r">
              <span class="vlist" style="height:1.32em;">
                <span style="top:-2.314em;">
                  <span class="pstrut" style="height:3em;"></span>
                  <span class="mord"><span class="mord mathnormal">b</span></span>
                </span>
                <span style="top:-3.23em;">
                  <span class="pstrut" style="height:3em;"></span>
                  <span class="frac-line" style="border-bottom-width:0.04em;"></span>
                </span>
                <span style="top:-3.677em;">
                  <span class="pstrut" style="height:3em;"></span>
                  <span class="mord"><span class="mord mathnormal">a</span></span>
                </span>
              </span>
              <span class="vlist-s">​</span>
            </span>
            <span class="vlist-r">
              <span class="vlist" style="height:0.686em;">
                <span></span>
              </span>
            </span>
          </span>
        </span>
        <span class="mclose nulldelimiter"></span>
      </span>
    </span>
  </span>
</span>

Text is selectable and searchable. Output scales with browser font size. Requires an external CSS stylesheet (~25KB minified) and web fonts (~200KB loaded on demand).

SVG (MathJax default) — renders to inline SVG:

html
<!-- Input: $\frac{a}{b}$ -->
<mjx-container class="MathJax" jax="SVG">
  <svg xmlns="http://www.w3.org/2000/svg"
       width="1.734ex" height="3.425ex"
       viewBox="0 -1342 766.7 1514">
    <g stroke="currentColor" fill="currentColor">
      <g data-mml-node="math">
        <g data-mml-node="mfrac">
          <g data-mml-node="mi" transform="translate(220, 676)">
            <path d="M33 157Q33 258 109 349T..."></path>
          </g>
          <g data-mml-node="mi" transform="translate(220, -686)">
            <path d="M73 647Q73 657 77 670T89..."></path>
          </g>
          <rect width="546.7" height="60" x="110" y="220"></rect>
        </g>
      </g>
    </g>
  </svg>
</mjx-container>

Pixel-perfect rendering across all browsers with no external fonts. Text is not selectable. Larger output size since each equation includes full path data.

MathML (native browser):

html
<!-- Input: a/b as a fraction -->
<math xmlns="http://www.w3.org/1998/Math/MathML">
  <mfrac>
    <mi>a</mi>
    <mi>b</mi>
  </mfrac>
</math>

Semantic markup that is natively accessible to screen readers with no JavaScript or CSS required. Rendering quality varies across browsers — Firefox has excellent support, Chrome and Safari less so.

Accessibility

MathJax invests heavily in accessibility: speech text generation for screen readers, Braille output, and interactive exploration of equation structure. KaTeX takes a simpler approach — it includes hidden MathML alongside the visual HTML, which allows screen readers to access equation structure but lacks MathJax's interactive features. If accessibility is a primary requirement, MathJax is the stronger choice.

Bundle Size

{}
KaTeX — Total: ~350KB
├── katex.min.js    (~95KB gzipped)
├── katex.min.css   (~25KB gzipped)
└── fonts/          (~200KB total, loaded on demand)
{}
MathJax — Total: ~500KB+ (varies by configuration)
├── tex-chtml.js    (~150KB gzipped, includes TeX parser + CHTML output)
├── output fonts    (~300KB, loaded on demand)
└── extensions      (additional size per extension)

KaTeX's rendering is synchronous — no layout shift, no flash of unstyled content. MathJax v3 is substantially faster than v2, but KaTeX remains faster for pure rendering speed. For build-time processing, the difference is negligible since both complete in milliseconds per equation.

When Each Makes Sense

Choose KaTeX when performance is critical, your math stays within its supported subset, you prefer HTML+CSS output, or you want the simplest setup. Choose MathJax when you need comprehensive LaTeX support, accessibility is a primary requirement, you prefer SVG output, or you need equation numbering and cross-references.

This site uses KaTeX. It covers the subset of LaTeX needed for the content here, and the synchronous rendering with zero client-side JavaScript aligns with the build-time rendering strategy. Switching to MathJax would require changing only the rehype plugin — remark-math stays the same.

Integration with Next.js

Dependencies

bash
npm install @next/mdx @mdx-js/loader @mdx-js/react
npm install remark-math rehype-katex
npm install katex  # for types and CSS

next.config.ts

typescript
import type { NextConfig } from 'next';
import createMDX from '@next/mdx';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
 
const nextConfig: NextConfig = {
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
};
 
const withMDX = createMDX({
  options: {
    remarkPlugins: [remarkMath],
    rehypePlugins: [rehypeKatex],
  },
});
 
export default withMDX(nextConfig);

Loading KaTeX CSS

KaTeX's CSS must be loaded for math to render correctly. Without it, the nested <span> elements have no positioning and equations appear as garbled text. On this site, the CSS is loaded globally in the root layout:

app/layout.tsx
import 'katex/dist/katex.min.css';
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Alternatively, link to the CDN in your HTML head:

html
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"
  integrity="sha384-nB0miv6/jRmo5UMMR1wu3Gz6NLsoTkbqJghGIsx//Rlm+ZU03BU6SQNC66uf4l5+"
  crossorigin="anonymous"
/>

mdx-components.tsx

For the App Router, you need an MDX components file at the project root:

mdx-components.tsx
import type { MDXComponents } from 'mdx/types';
 
export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    ...components,
    // Add any custom components here
  };
}

Example MDX File

mdx
---
title: Introduction to Calculus
---
 
# The Derivative
 
The derivative of a function $f$ at a point $x$ is defined as:
 
$$
f'(x) = \lim_{h \to 0} \frac{f(x + h) - f(x)}{h}
$$
 
This limit, when it exists, gives the instantaneous rate of change.
 
## Common Derivatives
 
| Function | Derivative |
|----------|------------|
| $x^n$ | $nx^{n-1}$ |
| $e^x$ | $e^x$ |
| $\ln x$ | $\frac{1}{x}$ |
| $\sin x$ | $\cos x$ |
| $\cos x$ | $-\sin x$ |
 
## The Chain Rule
 
For composite functions, the chain rule states:
 
$$
\frac{d}{dx}[f(g(x))] = f'(g(x)) \cdot g'(x)
$$
 
Or in Leibniz notation:
 
$$
\frac{dy}{dx} = \frac{dy}{du} \cdot \frac{du}{dx}
$$

Using MathJax Instead

To switch to MathJax, change your dependencies and config:

bash
npm install rehype-mathjax
next.config.ts
import rehypeMathjax from 'rehype-mathjax';
 
const withMDX = createMDX({
  options: {
    remarkPlugins: [remarkMath],
    rehypePlugins: [rehypeMathjax],  // Changed from rehypeKatex
  },
});

MathJax generates its CSS inline, so no external stylesheet is required. You may want to configure the output format:

typescript
import rehypeMathjax from 'rehype-mathjax/svg';  // SVG output
// or
import rehypeMathjax from 'rehype-mathjax/chtml';  // CommonHTML output
 
// For CHTML, you must specify the font URL:
const withMDX = createMDX({
  options: {
    remarkPlugins: [remarkMath],
    rehypePlugins: [
      [rehypeMathjax, {
        chtml: {
          fontURL: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/output/chtml/fonts/woff-v2'
        }
      }]
    ],
  },
});

Rendered Output Comparison

Given the input $\frac{a^2 + b^2}{c}$, here's what each renderer produces:

KaTeX (HTML+CSS):

html
<span class="katex">
  <span class="katex-mathml">
    <math xmlns="http://www.w3.org/1998/Math/MathML">
      <semantics>
        <mfrac>
          <mrow><msup><mi>a</mi><mn>2</mn></msup><mo>+</mo><msup><mi>b</mi><mn>2</mn></msup></mrow>
          <mi>c</mi>
        </mfrac>
        <annotation encoding="application/x-tex">\frac{a^2 + b^2}{c}</annotation>
      </semantics>
    </math>
  </span>
  <span class="katex-html" aria-hidden="true">
    <!-- Nested spans for visual rendering -->
  </span>
</span>

MathJax SVG:

html
<mjx-container class="MathJax" jax="SVG" style="position: relative;">
  <svg xmlns="http://www.w3.org/2000/svg" width="5.853ex" height="5.009ex"
       role="img" focusable="false" viewBox="0 -1450 2587 2214">
    <g stroke="currentColor" fill="currentColor" stroke-width="0">
      <!-- SVG path data for the rendered equation -->
    </g>
  </svg>
  <mjx-assistive-mml unselectable="on" display="inline">
    <math xmlns="http://www.w3.org/1998/Math/MathML">...</math>
  </mjx-assistive-mml>
</mjx-container>

MathJax CommonHTML:

html
<mjx-container class="MathJax" jax="CHTML">
  <mjx-math class="MJX-TEX">
    <mjx-mfrac>
      <mjx-frac>
        <mjx-num><mjx-nstrut></mjx-nstrut>
          <mjx-mrow>
            <mjx-msup>...</mjx-msup>
            <mjx-mo>+</mjx-mo>
            <mjx-msup>...</mjx-msup>
          </mjx-mrow>
        </mjx-num>
        <mjx-dbox><mjx-dtable><mjx-line></mjx-line></mjx-dtable></mjx-dbox>
        <mjx-den><mjx-dstrut></mjx-dstrut><mjx-mi>c</mjx-mi></mjx-den>
      </mjx-frac>
    </mjx-mfrac>
  </mjx-math>
</mjx-container>

Performance at Scale

For sites with many pages containing math, CSS loading strategy becomes important. KaTeX's stylesheet is ~25KB minified, and its fonts total ~200KB loaded on demand. On this site, the CSS is currently loaded globally — every page pays the cost whether it uses math or not.

As page count grows, conditional loading becomes worthwhile. Here is one approach — a client component that injects the stylesheet dynamically:

components/MathStyles.tsx
'use client';
 
import { useEffect } from 'react';
 
export function MathStyles() {
  useEffect(() => {
    // Dynamically inject the stylesheet
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
    document.head.appendChild(link);
 
    return () => {
      document.head.removeChild(link);
    };
  }, []);
 
  return null;
}

Then include it only in pages with math:

app/blog/[slug]/page.tsx
import { MathStyles } from '@/components/MathStyles';
 
export default function BlogPost({ params }) {
  const post = getPost(params.slug);
 
  return (
    <>
      {post.hasMath && <MathStyles />}
      <article>{post.content}</article>
    </>
  );
}

If you know a page has math, preload the fonts to avoid layout shift:

app/blog/[slug]/page.tsx
import { Metadata } from 'next';
 
export async function generateMetadata({ params }): Promise<Metadata> {
  const post = getPost(params.slug);
 
  return {
    title: post.title,
    // Preload math fonts if the page uses math
    ...(post.hasMath && {
      other: {
        'link': [
          {
            rel: 'preload',
            href: 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Main-Regular.woff2',
            as: 'font',
            type: 'font/woff2',
            crossOrigin: 'anonymous',
          },
        ],
      },
    }),
  };
}

For the fastest possible render, inline the subset of KaTeX CSS needed for above-the-fold equations:

tsx
// Extract and inline critical KaTeX styles
const criticalKatexCSS = `
.katex { font: normal 1.21em KaTeX_Main, serif; }
.katex .mord { font-family: KaTeX_Main; }
.katex .mfrac .frac-line { border-bottom-style: solid; }
/* ... other critical rules */
`;
 
export default function Layout({ children }) {
  return (
    <html>
      <head>
        <style dangerouslySetInnerHTML={{ __html: criticalKatexCSS }} />
        <link
          rel="stylesheet"
          href="/katex.min.css"
          media="print"
          onLoad="this.media='all'"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

The invariant across all these strategies: KaTeX CSS must be present when the page renders. Without it, the <span> elements that KaTeX produces have no positioning rules, and the equation collapses into unreadable text.

What This Article Doesn't Cover

Runtime rendering. If users can input equations (comments, live editors, interactive tools), you need client-side rendering. This requires loading KaTeX or MathJax in the browser and calling their APIs on user input. The build-time pipeline described here won't help.

Deep customization. Both renderers support extensive configuration: custom macros, theming, output tweaks, error handling. This article covers the basics; consult the official documentation for advanced use cases.

The implementation walkthrough. This article explains the architecture and tradeoffs of the pipeline. For a step-by-step tutorial with a working repository, see the companion GitHub project.

Conclusion

The math pipeline on this site makes three decisions:

  1. Input syntax: $...$ and $$...$$ delimiters, recognized by remark-math, writing to KaTeX's supported LaTeX subset.
  2. Output format: HTML+CSS, which keeps text selectable and scales with browser font size.
  3. Rendering engine: KaTeX, which renders synchronously at build time with zero client-side JavaScript.

These decisions are not tightly coupled. The input syntax is handled by remark-math regardless of which renderer runs downstream. The output format is determined by the rehype plugin — swapping rehype-katex for rehype-mathjax changes the output from HTML+CSS to SVG without touching any other configuration. The rendering engine choice is stable for now but replaceable by design.

The pipeline's central invariant is build-time rendering. Every equation is converted from LaTeX source to static HTML before the site is deployed. The browser receives finished markup. The only thing it needs to display math correctly is a CSS stylesheet — and that is the one remaining piece to optimize as the site grows.

Was this article helpful?