Server-Side Mermaid Rendering Without the Browser

Topics: Languages & Runtimes, Functional & Concurrency, Web Development

Server-side Mermaid diagram rendering has always required browser environments or complex DOM simulation. Puppeteer, JSDOM, heavyweight solutions that feel wrong for what should be straightforward parsing and SVG generation.

So I built @rendermaid/core - a pure TypeScript Mermaid renderer that runs server-side without any browser dependencies. Here's what makes it work.

What This Actually Does

The core idea is simple: parse Mermaid diagram syntax into an AST, then render that AST directly to SVG using functional TypeScript patterns. No DOM, no browser APIs, just types and pure functions.

The latest version (v0.6.0) handles:

  • Native TypeScript parsing - Zero browser dependencies
  • Markdown integration - Extract and render diagrams directly from markdown files
  • Multiple output formats - SVG, HTML, JSON, and round-trip Mermaid generation
  • Smart canvas sizing - Dynamic configuration prevents content clipping
  • Validation and analysis - AST validation, complexity scoring, cycle detection

Here's the basic usage:

import { parseMermaid, renderSvg } from "@rendermaid/core";

const diagram = `
flowchart TD
    A[Start] --> B{Decision}
    B -->|Yes| C[Process]
    B -->|No| D[Skip]
    C --> E[End]
    D --> E
`;

const parseResult = parseMermaid(diagram);
if (parseResult.success) {
  const svgResult = renderSvg(parseResult.data, {
    width: 800,
    height: 600,
    theme: "light",
    nodeSpacing: 120
  });

  console.log(svgResult.data); // Clean SVG output
}

No Puppeteer launch. No JSDOM setup. Parse and render, done.

Type-Safe AST Design

The AST uses discriminated unions with full type safety. To me is interesting that TypeScript's type system makes invalid diagram states impossible to represent:

// Node types with comprehensive shape support
type MermaidNode = {
  readonly id: string;
  readonly label: string;
  readonly shape: "rectangle" | "rounded" | "circle" | "rhombus" | "hexagon" | "stadium";
  readonly metadata?: ReadonlyMap<string, unknown>;
};

// Edge types with connection variety
type MermaidEdge = {
  readonly from: string;
  readonly to: string;
  readonly label?: string;
  readonly type: "arrow" | "line" | "thick" | "dotted" | "dashed";
  readonly metadata?: ReadonlyMap<string, unknown>;
};

// Complete AST representation
type MermaidAST = {
  readonly diagramType: DiagramType;
  readonly nodes: ReadonlyMap<string, MermaidNode>;
  readonly edges: readonly MermaidEdge[];
  readonly metadata: ReadonlyMap<string, unknown>;
};

Everything is readonly. Mutations are compile errors. The type system guarantees structural integrity.

Pattern Matching for Parsing

The parser uses pre-compiled regex patterns with TypeScript's const assertions for performance:

// Shape patterns compiled once
const SHAPE_PATTERNS = [
  { pattern: /([a-zA-Z_][a-zA-Z0-9_]*)\(\[([^\]]+)\]\)/, shape: "stadium" as const },
  { pattern: /([a-zA-Z_][a-zA-Z0-9_]*)\(\(([^)]+)\)\)/, shape: "circle" as const },
  { pattern: /([a-zA-Z_][a-zA-Z0-9_]*)\{\{([^}]+)\}\}/, shape: "hexagon" as const },
  { pattern: /([a-zA-Z_][a-zA-Z0-9_]*)\{([^}]+)\}/, shape: "rhombus" as const },
  { pattern: /([a-zA-Z_][a-zA-Z0-9_]*)\(([^)]+)\)/, shape: "rounded" as const },
  { pattern: /([a-zA-Z_][a-zA-Z0-9_]*)\[([^\]]+)\]/, shape: "rectangle" as const }
] as const;

// Connection patterns for edge types
const CONNECTION_PATTERNS = [
  { pattern: /-.->/, type: "dotted" as const },
  { pattern: /==>/, type: "thick" as const },
  { pattern: /---/, type: "dashed" as const },
  { pattern: /-->/, type: "arrow" as const }
] as const;

Look at this - the patterns are tested once at compile time, then reused at runtime. TypeScript infers the exact literal types automatically.

Smart Canvas Sizing

Earlier versions had a frustrating problem - bottom elements would get clipped because canvas height was fixed. Version 0.6.0 solves this with dynamic configuration:

const calculateDynamicConfig = (analysis: DiagramAnalysis): SvgConfig => {
  const depth = analysis.depth || 3;
  const nodeHeight = 60;
  const layerSpacing = 120 * 1.5;
  const topPadding = 80;
  const bottomPadding = 120; // Generous bottom padding
  const edgeLabelPadding = 40;

  const calculatedHeight = topPadding + (depth * layerSpacing) +
                          nodeHeight + bottomPadding + edgeLabelPadding;

  // Complex diagrams get minimum 1000x1000px canvas
  if (analysis.complexity > 20) {
    return {
      width: Math.max(1000, baseWidth),
      height: Math.max(1000, calculatedHeight),
      theme: "light",
      nodeSpacing: 120
    };
  }

  return {
    width: 800,
    height: Math.max(800, calculatedHeight),
    theme: "light",
    nodeSpacing: 120
  };
};

The algorithm analyzes diagram depth and complexity, then calculates canvas size. Simple diagrams get compact output. Complex diagrams get the space they need. No more clipped content.

Validation and Analysis

Version 0.6.0 adds comprehensive validation that catches structural issues:

import { parseMermaid, validateAST, analyzeAST } from "@rendermaid/core";

const diagram = `
flowchart TD
    A[Start] --> B{Decision}
    B -->|Yes| C[Process]
    B -->|No| D[Skip]
    C --> E[End]
    D --> E
`;

const parseResult = parseMermaid(diagram);
if (parseResult.success) {
  const ast = parseResult.data;

  // Validate diagram integrity
  const validationErrors = validateAST(ast);
  if (validationErrors.length > 0) {
    console.log("Validation issues:", validationErrors);
    // Example: ["Edge references non-existent source node: X"]
  }

  // Analyze diagram complexity and structure
  const analysis = analyzeAST(ast);
  console.log("Complexity score:", analysis.complexity);
  console.log("Node shapes used:", analysis.nodeShapes);
  console.log("Edge types used:", analysis.edgeTypes);
  console.log("Maximum depth:", analysis.depth);
  console.log("Contains cycles:", analysis.cycleDetected);
}

Here's the cool part - validation runs at multiple levels. Parser validates syntax. AST validation checks structural integrity (edges reference valid nodes). Analysis provides metrics for optimization.

Markdown Integration

One feature that surprised me with how useful it turned out - direct markdown file processing:

import { extractMermaidFromMarkdown, parseMermaid, renderSvg } from "@rendermaid/core";

const markdownContent = `
# My Document

Here's a diagram:

\`\`\`mermaid
flowchart TD
    A[Start] --> B[Process]
    B --> C[End]
\`\`\`

More content here...
`;

const diagrams = extractMermaidFromMarkdown(markdownContent);
diagrams.forEach(diagram => {
  const parseResult = parseMermaid(diagram);
  if (parseResult.success) {
    const svgResult = renderSvg(parseResult.data);
    console.log(svgResult.data); // Rendered SVG
  }
});

This works beautifully for static site generators. Extract diagrams, render to SVG, embed in HTML. All server-side, no client JavaScript needed.

Performance Monitoring

Built-in performance monitoring makes optimization straightforward:

import { withPerformanceMonitoring, renderSvg } from "@rendermaid/core";

// Wrap rendering with performance monitoring
const monitoredRender = withPerformanceMonitoring(renderSvg, "Complex Diagram");

const result = monitoredRender(ast, {
  width: 1200,
  height: 800,
  theme: "light",
  nodeSpacing: 150
});

// Console output: "⏱️ Complex Diagram: 12.34ms"

The tricky bit was keeping monitoring overhead minimal. Simple wrapper function, minimal allocations, precise timing.

Spatial Grid Rendering

The layout algorithm uses spatial grid optimization for collision detection:

// O(1) collision detection using spatial hashing
const optimizedCalculateLayout = (ast: MermaidAST, config: SvgConfig) => {
  // Spatial grid for fast collision queries
  const spatialGrid = new SpatialGrid(100);

  // Layer-based layout using BFS for optimal positioning
  const layers = calculateLayers(ast);

  // Position nodes with collision avoidance
  return positionNodesWithCollisionAvoidance(layers, spatialGrid, config);
};

This means the renderer handles complex diagrams (50+ nodes, 100+ edges) without performance degradation. The spatial grid reduces collision checks from O(n²) to O(1).

Multi-Format Output

Beyond SVG, the renderer supports multiple output formats:

HTML with accessibility:

import { renderHtml } from "@rendermaid/core";

const htmlResult = renderHtml(ast, {
  className: "my-diagram",
  includeStyles: true,
  responsive: true
});
// Semantic HTML with proper ARIA labels

JSON for data processing:

import { renderJson } from "@rendermaid/core";

const jsonResult = renderJson(ast, {
  pretty: true,
  includeMetadata: true
});
// Structured data for further processing

Round-trip Mermaid generation:

import { renderMermaid } from "@rendermaid/core";

// Convert AST back to Mermaid syntax
const mermaidResult = renderMermaid(ast, {
  preserveFormatting: true,
  includeComments: false
});

The last one is particularly useful for diagram normalization and formatting.

Migration from Earlier Versions

Version 0.6.0 is fully backward compatible with v0.5.0 - no syntax changes required. Just update the dependency.

For migrations from v0.4.0 or earlier, the main change is diagram syntax:

// ❌ Old syntax (v0.4.0 and earlier)
const oldDiagram = `
graph TD
    A[Start] --> B[Process]
    B --> C[End]
`;

// ✅ New syntax (v0.5.0+)
const newDiagram = `
flowchart TD
    A[Start] --> B[Process]
    B --> C[End]
`;

Use flowchart TD instead of graph TD. That's it.

Real Talk: What Works and What Doesn't

I've been using this in production for several months now. Here's the honest assessment.

Where It Shines

Server-side rendering is effortless. No browser automation, no JSDOM complexity. Parse and render in the same process.

Static site generation is perfect. Extract diagrams from markdown, render at build time, serve static HTML. Fast and simple.

Performance is excellent. Complex diagrams render in under 50ms. The spatial grid optimization actually works.

Type safety catches bugs early. Invalid diagram structures fail at compile time, not runtime.

Testing is straightforward. Pure functions, deterministic output, easy assertions.

Where It Falls Short

Limited diagram types. Currently supports flowcharts only. Sequence diagrams are planned but not implemented.

Syntax is stricter than official Mermaid. Some edge cases from the official parser aren't supported yet.

SVG output is functional but basic. Professional polish requires post-processing for some use cases.

No client-side interactivity. This generates static SVG. If you need zoom, pan, or click handlers, you need additional JavaScript.

When to Use This

This approach works best for:

  • Static site generators and blogs
  • Documentation systems with embedded diagrams
  • Server-side PDF generation
  • Build-time diagram processing
  • Deno and Node.js backends

Skip it for:

  • Real-time collaborative diagramming
  • Complex interactive diagrams needing zoom/pan
  • Full Mermaid syntax compatibility requirements
  • Client-side rendering applications

Getting Started

Installation depends of your runtime:

# Deno
deno add jsr:@rendermaid/core

# Node.js/Bun
npx jsr add @rendermaid/core

Basic example to verify everything works:

import { parseMermaid, renderSvg } from "@rendermaid/core";

const diagram = `
flowchart TD
    A[Start] --> B{Decision}
    B -->|Yes| C[Process]
    B -->|No| D[Skip]
    C --> E[End]
    D --> E
`;

const parseResult = parseMermaid(diagram);
if (parseResult.success) {
  const svgResult = renderSvg(parseResult.data, {
    width: 800,
    height: 600,
    theme: "light",
    nodeSpacing: 120
  });

  if (svgResult.success) {
    console.log(svgResult.data); // Clean SVG output
  }
}

If you see SVG markup, it's working.

What I Learned Building This

The interesting part was how functional patterns simplified complex problems. Pure functions for parsing, immutable AST, explicit Result types for error handling - these patterns made testing and debugging straightforward.

The dynamic canvas sizing took several iterations to get right. Early versions either clipped content or wasted space. The current algorithm balances both concerns reasonably well.

Performance monitoring revealed surprising bottlenecks. String concatenation for SVG generation was slow. Pre-allocated arrays with join improved render time by 40%.

Type safety caught numerous bugs during development. Invalid edge references, shape mismatches, missing node IDs - all caught at compile time instead of runtime.

I built this while working on documentation for my band's website. Needed server-side diagram rendering without Puppeteer overhead. The initial version handled basic flowcharts. Each iteration added features based on actual usage patterns.

This won't replace the official Mermaid library for all use cases. But for server-side rendering in TypeScript? It works surprisingly well. Worth trying if you're building static sites or documentation systems.