How to render Mermaid diagrams in Astro MDX
I wanted to write flowcharts in my posts the way everyone does on GitHub: open a code fence tagged mermaid, write the graph, done. But when I tried it in my Astro blog, I didn’t get a diagram — I got a syntax-highlighted code block showing the raw graph text. This post explains why that happens and the small remark plugin I wrote to fix it.
Why Shiki eats your mermaid fence
Mermaid is a JavaScript library that turns text like graph TD; A-->B into an SVG diagram. But mermaid runs in the browser — it needs a real DOM. Astro, on the other hand, renders your Markdown at build time in Node, where there’s no browser.
The bigger problem is what Astro does with fenced code blocks. Astro ships with Shiki, a syntax highlighter. When Astro builds your post, Shiki looks at the language tag on every fence and turns the block into pre-colored HTML. From Shiki’s point of view, a fence tagged mermaid is just source code in a language it should color. So instead of a diagram, mermaid’s graph text gets wrapped in a highlighted <pre> and that’s the end of it — the browser never gets a chance to render anything.
Here’s the pipeline for a normal fence in my config:
// blog/astro.config.mjs
markdown: {
processor: unified({
remarkPlugins: [remarkReadingTime, remarkMermaid],
rehypePlugins: [rehypeCodeToolbar],
}),
shikiConfig: {
theme: "github-dark",
wrap: true,
},
},The order matters here. Remark plugins run first, on the Markdown syntax tree (the “mdast”). Rehype plugins and Shiki run later, on the HTML tree. (If that pipeline is new to you, I walk through it in opting into the unified remark/rehype pipeline in Astro 7.) So the trick is: if I can catch the mermaid fence during the remark pass and change what it is before Shiki ever looks at it, Shiki will leave it alone.
The plan
A code fence in the remark tree is a node of type code with a lang property. If I find the ones where lang === "mermaid" and rewrite them into raw HTML nodes, they stop being code blocks. Shiki only highlights code nodes, so a raw HTML node passes straight through untouched.
I rewrite each mermaid fence into a <pre class="mermaid">. That specific class is what the mermaid runtime looks for on the client. So the flow becomes:
graph LR A["```mermaid fence"] --> B["remark-mermaid<br/>rewrites to raw HTML"] B --> C["Shiki skips it<br/>(not a code node)"] C --> D["pre.mermaid in HTML"] D --> E["client runtime<br/>renders SVG"]
The remark plugin
Here’s the whole plugin — it’s genuinely this small:
// blog/src/utils/remark-mermaid.mjs
import { visit } from "unist-util-visit"
const escapeHtml = (s) =>
s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
export function remarkMermaid() {
return function (tree) {
visit(tree, "code", (node) => {
if (node.lang !== "mermaid") return
node.type = "html"
node.value = `<div class="mermaid-figure"><pre class="mermaid" aria-label="Diagram">${escapeHtml(
node.value,
)}</pre></div>`
})
}
}Let me walk through it.
visit(tree, "code", ...) comes from unist-util-visit, a helper that walks the syntax tree and calls my function for every node of a given type — here, every code node (fenced code block).
if (node.lang !== "mermaid") return bails out on anything that isn’t a mermaid fence, so normal code blocks are left completely alone and still get highlighted by Shiki.
The two important lines are node.type = "html" and node.value = .... Changing type from "code" to "html" is the whole point: the node is no longer a code block, so Shiki ignores it and the string I put in node.value is emitted as-is into the final HTML. I wrap the graph in <div class="mermaid-figure"> (a hook for styling and the lightbox) and a <pre class="mermaid"> (the element the runtime finds).
Why the HTML-escaping matters
Notice escapeHtml(node.value). Mermaid graph syntax often contains <, >, and & — think edge labels or subgraph arrows. If I dropped raw < characters into an HTML string, the browser’s parser would think they were the start of tags and mangle the markup. So I escape them to <, >, and & first, which keeps the HTML valid.
That raises a fair question: if I escaped the source, won’t mermaid render < literally instead of <? No — and this is the neat part. The runtime reads the graph back out with node.textContent, and textContent returns the un-escaped text. So < in the markup becomes < again by the time mermaid parses it. The diagram sees exactly the original source; the escaping only ever protected the trip through HTML.
Rendering it on the client
After the build, the browser receives a <pre class="mermaid"> full of graph text but no diagram yet. A client script (in my PostEnhancements.astro) finishes the job:
// blog/src/components/PostEnhancements.astro (trimmed)
const nodes = Array.from(
document.querySelectorAll<HTMLElement>("pre.mermaid:not([data-processed])"),
)
if (nodes.length === 0) return
const { default: mermaid } = await import("mermaid")
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "base" })
for (const [i, node] of nodes.entries()) {
const source = node.textContent ?? ""
node.setAttribute("data-processed", "")
const { svg } = await mermaid.render(`mermaid-svg-${i}-${Date.now()}`, source)
// ...swap the <pre> for the rendered SVG
}The script grabs every pre.mermaid that hasn’t been processed yet, reads the source with textContent (the un-escaping I mentioned), and calls mermaid.render() to get SVG. Note it imports mermaid with a dynamic import("mermaid") inside the guard, and returns early when there are no diagram nodes — so pages without a diagram never download the library. (That’s a topic on its own; I covered it in lazy-loading the Mermaid runtime only on pages with a diagram.) The theme: "base" and color choices in initialize() are also worth a post of their own — see theming Mermaid when your palette uses OKLCH.
Takeaways
- Astro’s Shiki highlighter treats a
mermaidfence as code and colors it instead of rendering a diagram. - Remark plugins run on the Markdown tree before Shiki, so that’s where you intercept.
- Flip the node’s
typefrom"code"to"html"and emit a<pre class="mermaid">— Shiki only touches code nodes, so it skips yours. - HTML-escape the graph source so the markup stays valid;
textContentun-escapes it for mermaid on the client. - Actual SVG rendering happens in the browser, where mermaid can use a real DOM.