When Build-Time Rendering Seemed Like a Good Idea

- Published on
- /9 mins read
📚 Series: Mermaid Diagrams in Next.js
- Part 1: When Build-Time Rendering Seemed Like a Good Idea ← You are here
- Part 2: Building Interactive Controls I Didn't Need
- Part 3: Simple CSS Fixed Everything
TL;DR Series: Tried build-time rendering (failed), built 706 lines of pan/zoom (over-engineered), deleted 95.6% and shipped 3 lines of CSS (success).
Introduction: A Journey From Complexity to Simplicity
📦 Mermaid Version Note: This tutorial uses Mermaid v10.9.1. The code examples and API calls shown are compatible with v10.x.
For Mermaid v11.x users: Breaking API change in v11.0.0+
- Replace
mermaid.initialize()withawait mermaid.run()- See the official migration guide for full details
- Bundle size increased from ~373 KB (v10.x) to ~480 KB (v11.x)
Started a project thinking "this will be simple," only to find yourself knee-deep in complexity weeks later? That's what happened when I decided to add diagram support to my blog.
I'm building a technical blog using Next.js and MDX (Markdown with React components). I wanted to write diagrams directly in my markdown files using Mermaid.js, a popular diagramming library. What seemed like a weekend project turned into an exploration through three completely different architectures, with valuable lessons about engineering trade-offs along the way.
This series covers:
- Part 1 (this post): Server-side SVG generation (spoiler: hit a wall)
- Part 2: Interactive pan/zoom controls (over-engineered)
- Part 3: Simple client-side rendering (perfect)
Understanding the Problem
What is Mermaid.js?
Mermaid.js is a JavaScript library that turns text into diagrams. Instead of using design tools, you write code like this:
And it renders as an interactive SVG diagram. Perfect for technical documentation!
The Goal
As a content author, I want to write this in my MDX files:
Here's how our system works:
\`\`\`mermaid
graph TD
A[Start] --> B[Process]
B --> C[End]
\`\`\`And have it automatically render as a beautiful diagram when readers visit the page.
Technical Requirements
Let me break down what "working correctly" means:
- Author-friendly: I should write standard markdown, not HTML or complex syntax
- Theme-aware: Diagrams should automatically match light/dark mode
- High-quality rendering: SVG should be crisp, not pixelated, at all zoom levels
- Performant: Fast page loads, minimal JavaScript bundle size
- Simple maintenance: Less code = fewer bugs = easier future changes
Now, with these requirements clear, let's explore how to build it.
Architecture 1: Build-Time SVG Generation (The Ambitious Approach)
The Idea: Why Not Generate SVGs During Build?
When I started researching, I thought: "Why send Mermaid.js (~373 KB in v10.x, ~480 KB in v11.x) to every visitor? Why not generate diagrams once during build and ship static SVG?"
This is called build-time rendering or static site generation (SSG). The concept is simple:
Traditional approach (what most people do):
1. User visits page
2. Browser downloads Mermaid.js (~373 KB)
3. Browser parses diagram code
4. Browser renders SVG
5. User sees diagram (slow)
My idea (build-time):
1. During `npm run build`, generate SVG
2. Inject SVG directly into HTML
3. User visits page
4. SVG is already there (instant!)
Expected benefits:
- ⚡ Instant rendering - No client-side JavaScript needed
- 📦 Smaller bundles - Don't ship ~373 KB Mermaid library
- 🚀 Better SEO - Search engines see SVG immediately
- 🎨 Consistency - Same SVG every time, no rendering bugs
Sounds perfect, right? Let's see how I tried to build it.
Understanding the Build Pipeline
To implement this, I needed to hook into Next.js's build process. Here's how Next.js transforms markdown into HTML:
What are Remark and Rehype?
- Remark processes markdown → transforms syntax (this is where we'd intercept Mermaid blocks)
- Rehype processes HTML → adds syntax highlighting, etc.
My plan was to create a custom Remark plugin that:
- Finds all code blocks with
lang="mermaid" - Runs Mermaid.js to generate SVG
- Replaces the code block with the SVG in the AST (Abstract Syntax Tree)
Step 1: Creating the Remark Plugin
Here's the code I wrote:
import { visit } from 'unist-util-visit'
import mermaid from 'mermaid'
export function remarkMermaidBuildTime() {
return async (tree: any) => {
const codeBlocks: any[] = []
// Step 1: Walk through the markdown AST (Abstract Syntax Tree)
// and collect all code blocks with lang="mermaid"
visit(tree, 'code', (node, index, parent) => {
if (node.lang === 'mermaid') {
// Store references so we can modify them later
codeBlocks.push({ node, index, parent })
}
})
// Step 2: Generate SVG for each Mermaid block
for (const { node, index, parent } of codeBlocks) {
// node.value contains the diagram code like "graph TD\n A-->B"
const svg = await mermaid.render(`mermaid-${index}`, node.value)
// Step 3: Replace the code block node with raw HTML containing SVG
parent.children[index] = {
type: 'html', // Tell the parser this is HTML, not markdown
value: svg // The actual SVG string
}
}
}
}What this code does:
visit(tree, 'code', ...)- This walks through every code block in the markdownif (node.lang === 'mermaid')- Checks if it's a Mermaid diagram (not JavaScript, Python, etc.)mermaid.render(...)- Calls Mermaid.js to generate SVG from the text- Replace node - Swaps the code block with the SVG
Seems straightforward! But there's a problem we'll discover soon.
Step 2: Solving the Theme Problem
Before we continue, there's a tricky challenge: how do we support light/dark mode with static SVGs?
During build, we don't know which theme the user will choose. The build runs once, but users can toggle themes. Here were my options:
Option A: Generate both light and dark SVGs
// Generate TWO versions of each diagram
const lightSvg = await mermaid.render(id, chart, {
theme: 'default' // Light theme
})
const darkSvg = await mermaid.render(id, chart, {
theme: 'dark' // Dark theme
})
// Wrap with conditional CSS classes
const html = `
<div class="light:block dark:hidden">${lightSvg}</div>
<div class="light:hidden dark:block">${darkSvg}</div>
`Pros: Works perfectly Cons: Doubles the HTML size, generates each diagram twice
Option B: Use CSS variables (smarter)
const svg = await mermaid.render(id, chart, {
theme: 'base', // Neutral base theme
themeVariables: {
primaryColor: 'var(--diagram-primary)', // CSS variable
secondaryColor: 'var(--diagram-secondary)',
// These CSS variables change when user toggles theme
}
})Pros: One SVG, adapts automatically Cons: Requires careful CSS setup
I was leaning toward Option B when I hit a much bigger problem...
The Reality Check: When Node.js Isn't a Browser
I added my plugin to contentlayer.config.ts:
import { remarkMermaidBuildTime } from './utils/remark-mermaid-build-time'
export default makeSource({
mdx: {
remarkPlugins: [
remarkMermaidBuildTime, // Our custom plugin
// ... other plugins
],
},
})Then ran the build:
$ npm run buildAnd immediately got an error:
Error: document is not defined
at node_modules/mermaid/dist/mermaid.core.mjs:47423:15What happened?
Mermaid.js uses browser-only APIs like document, window, and the DOM. These don't exist in Node.js (the build environment). The library was literally trying to call document.createElement() during the build, which crashed.
Why this happens:
- Browser environment: Has
window,document, DOM APIs - Node.js environment (build): No browser APIs, just server-side JavaScript
Mermaid was built for browsers, not servers.
Attempt #1.5: Using Puppeteer (The Headless Browser Approach)
I found @mermaid-js/mermaid-cli, which wraps Mermaid in Puppeteer (a headless Chrome browser). This would let me run browser code during builds:
npm install @mermaid-js/mermaid-cliimport { run } from '@mermaid-js/mermaid-cli'
// Generate diagram by launching headless Chrome
await run(
'input.mmd', // Input file with Mermaid code
'output.svg', // Output SVG
{ parseMMDOptions: { theme: 'default' } }
)This technically works, but...
New problems:
- Puppeteer adds 280 MB to
node_modules(it bundles Chromium) - Build time explodes - Launching Chrome for each diagram takes seconds
- CI/CD nightmare - GitHub Actions, Docker need Chrome installed
- Development friction - Every markdown change triggers slow rebuild
For a blog with 10 posts × 3 diagrams each = 30 Chrome launches per build. My local builds went from 5 seconds to 45 seconds.
Architecture 1: Final Verdict
Let me weigh the trade-offs honestly:
| Aspect | Score | Reality |
|---|---|---|
| Runtime Performance | 🟢 Great | Zero JavaScript shipped to users |
| Bundle Size | 🟢 Great | No ~373 KB Mermaid library |
| Build Performance | 🔴 Terrible | 45 second builds (was 5 seconds) |
| Developer Experience | 🔴 Terrible | Slow hot-reload, painful development |
| Maintenance | 🔴 Terrible | 280 MB Puppeteer dependency |
| Complexity | 🔴 High | Headless browser, CLI integration |
| Theme Support | 🟡 Hacky | Need workarounds for light/dark |
Decision: ❌ Abandoned
Why I gave up:
The theoretical benefits (faster page loads, smaller bundles) weren't worth the practical costs:
- My blog has ~5-10 diagrams per post, not hundreds
- The Mermaid library (373 KB in v10.x, 480 KB in v11.x), when code-split, loads in ~200ms on 3G
- But my development experience became horrible
- Build times went from 5s → 45s (9x slower!)
Key lesson learned: Don't optimize for problems you don't have. I was solving for "millions of diagrams" when I had dozens. This is called premature optimization, and it's a trap.
👉 Continue to Part 2: Building Interactive Pan/Zoom Controls I Didn't Need →
Note on bundle sizes: The 373 KB figure referenced throughout this series was accurate for Mermaid.js v10.x at the time of writing. Version 11.x has grown to ~480 KB due to additional features. The core architectural lessons remain valid regardless of the exact bundle size.
In the next post, I'll show you how I built 706 lines of smooth momentum scrolling, keyboard shortcuts, and hover-only UI controls—and why I eventually deleted all of it.
On this page
- TL;DR Series: Tried build-time rendering (failed), built 706 lines of pan/zoom (over-engineered), deleted 95.6% and shipped 3 lines of CSS (success).
- Introduction: A Journey From Complexity to Simplicity
- Understanding the Problem
- What is Mermaid.js?
- The Goal
- Technical Requirements
- Architecture 1: Build-Time SVG Generation (The Ambitious Approach)
- The Idea: Why Not Generate SVGs During Build?
- Understanding the Build Pipeline
- Step 1: Creating the Remark Plugin
- Step 2: Solving the Theme Problem
- The Reality Check: When Node.js Isn't a Browser
- Attempt #1.5: Using Puppeteer (The Headless Browser Approach)
- Architecture 1: Final Verdict
- In the next post, I'll show you how I built 706 lines of smooth momentum scrolling, keyboard shortcuts, and hover-only UI controls—and why I eventually deleted all of it.
