José David Baena

When Build-Time Rendering Seemed Like a Good Idea

Banner.jpeg
Published on
/9 mins read

📚 Series: Mermaid Diagrams in Next.js

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() with await 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:

  1. Part 1 (this post): Server-side SVG generation (spoiler: hit a wall)
  2. Part 2: Interactive pan/zoom controls (over-engineered)
  3. 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:

Loading diagram...

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:

  1. Author-friendly: I should write standard markdown, not HTML or complex syntax
  2. Theme-aware: Diagrams should automatically match light/dark mode
  3. High-quality rendering: SVG should be crisp, not pixelated, at all zoom levels
  4. Performant: Fast page loads, minimal JavaScript bundle size
  5. 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:

Loading diagram...

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:

  1. Finds all code blocks with lang="mermaid"
  2. Runs Mermaid.js to generate SVG
  3. 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:

utils/remark-mermaid-build-time.ts
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:

  1. visit(tree, 'code', ...) - This walks through every code block in the markdown
  2. if (node.lang === 'mermaid') - Checks if it's a Mermaid diagram (not JavaScript, Python, etc.)
  3. mermaid.render(...) - Calls Mermaid.js to generate SVG from the text
  4. 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:

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 build

And immediately got an error:

Error: document is not defined
    at node_modules/mermaid/dist/mermaid.core.mjs:47423:15

What 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-cli
import { 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:

  1. Puppeteer adds 280 MB to node_modules (it bundles Chromium)
  2. Build time explodes - Launching Chrome for each diagram takes seconds
  3. CI/CD nightmare - GitHub Actions, Docker need Chrome installed
  4. 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:

AspectScoreReality
Runtime Performance🟢 GreatZero JavaScript shipped to users
Bundle Size🟢 GreatNo ~373 KB Mermaid library
Build Performance🔴 Terrible45 second builds (was 5 seconds)
Developer Experience🔴 TerribleSlow hot-reload, painful development
Maintenance🔴 Terrible280 MB Puppeteer dependency
Complexity🔴 HighHeadless browser, CLI integration
Theme Support🟡 HackyNeed 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.