Back to blog

Building this site: Astro + MathJax + Shiki, zero-JS

A build log for the blog you are reading. How Astro 7 renders LaTeX to static SVG and code to dual-theme highlighting entirely at build time, so an article full of equations still ships zero client-side JavaScript.

I wanted a research blog where an equation-heavy post costs the reader nothing at runtime. No MathJax script downloading fonts, no syntax-highlighter booting on load, no layout shift while a formula typesets. Just HTML, some SVG, and CSS. Astro 7 makes that achievable, but a couple of the moving parts are non-obvious, so this is the log of how the pipeline actually fits together. It doubles as documentation for future me.

The processor question: satteri vs unified

Astro 7 ships a new default Markdown pipeline called satteri. It is fast, but it deliberately does not run the unified ecosystem of remark and rehype plugins. That is fine for plain prose — and a problem the moment you want LaTeX, because the tools that turn $...$ into math are unified plugins.

So the first decision is to opt back into the classic unified() processor, which is still shipped inside @astrojs/markdown-remark. You lose a little build speed and get back the entire plugin ecosystem. For a site that leans on equations that is the right trade. Two plugins do the math work:

  • remark-math parses $...$ and $$...$$ in the Markdown AST into dedicated math nodes, so they are no longer treated as literal text.
  • rehype-mathjax takes those nodes and typesets them with MathJax. Its default export emits SVG at build time — the glyphs become paths in the HTML, with no client script and no web-font request.

Crucially, MathJax runs on my machine during astro build, not in the reader’s browser. The equation below is, in the shipped page, a chunk of inline SVG:

View source on this page and you will find mjx-container elements holding <svg> paths — not a <script> tag pointing at a CDN. That is the whole point.

Wiring it up in astro.config.mjs

Here is the abbreviated Markdown section of the config that makes it work. The comments are the part worth reading:

import { defineConfig } from 'astro/config';
// The unified() processor still lives in @astrojs/markdown-remark. Opting into
// it is what re-enables remark/rehype plugins under Astro 7's satteri default.
import { unified } from '@astrojs/markdown-remark';
import remarkMath from 'remark-math';
import rehypeMathjax from 'rehype-mathjax'; // default export === SVG output

export default defineConfig({
  markdown: {
    // Shiki runs at build time too; dual themes emit CSS variables only.
    syntaxHighlight: 'shiki',
    shikiConfig: {
      themes: { light: 'github-light', dark: 'github-dark' },
      defaultColor: false, // do not bake one theme in; drive it from CSS
      wrap: false,
    },
    // remark-math + rehype-mathjax are unified plugins, so the processor
    // must be unified(). @astrojs/mdx inherits this same processor.
    processor: unified({
      remarkPlugins: [remarkMath],
      rehypePlugins: [rehypeMathjax],
    }),
  },
});

One nicety: because @astrojs/mdx inherits the same processor, this .mdx file gets identical math and code handling as the plain Markdown posts. I did not have to configure anything twice.

Shiki, highlighted once, themed twice

Code highlighting has the same zero-JS constraint. Shiki solves it by tokenising and colouring code during the build — the output is already-coloured HTML, so there is nothing to run on load. The interesting flag above is defaultColor: false. With it, Shiki does not bake a single theme’s colours into inline styles. Instead it emits CSS custom properties for both the light and dark themes at once, and a stylesheet decides which set applies:

/* Light theme (default) reads the light variables. */
.astro-code,
.astro-code span {
  color: var(--shiki-light);
  background-color: var(--shiki-light-bg);
}

/* A data-theme toggle swaps the whole block to the dark variables —
   no re-highlighting, no JavaScript, just a variable switch. */
:root[data-theme='dark'] .astro-code,
:root[data-theme='dark'] .astro-code span {
  color: var(--shiki-dark);
  background-color: var(--shiki-dark-bg);
}

Flip the theme toggle in the header and every code block on the page recolours instantly, because all that changes is which variable each token reads. The highlighting itself was computed once, at build.

What ships to the reader

Add it up and an article like this one — several display equations, multiple highlighted code blocks — arrives as:

  • HTML for the prose and structure,
  • inline SVG for every equation, coloured with currentColor so it follows the text colour in both themes,
  • pre-coloured code whose theme is a CSS-variable switch away,
  • and no article-specific JavaScript at all.

The only script on the page is a tiny inline theme-boot snippet in the document head that sets data-theme before first paint to avoid a flash of the wrong theme. Everything else that looks dynamic — math, highlighting, light and dark — was resolved before the file ever left the build.

That is the property I was after: the reader pays for the bytes and nothing else. The complexity lives in the build, where it belongs, and the page stays a document.