achmadya.dev
~/writing / rendering-markdown-and-mermaid

How I render Markdown and Mermaid in React

ReactMarkdownMermaid
Content language

The problem was not parsing Markdown

Rendering Markdown with react-markdown is straightforward until one fenced code block must become an interactive component.

I wanted ordinary code to keep syntax highlighting while a block marked mermaid became an SVG diagram. Invalid diagrams also needed a readable fallback instead of breaking the article.

The first implementation treated every code block the same. Mermaid source appeared as plain text, and later CSS fixes made diagrams either too large or inconsistent with the rest of the page.

The useful design question became: where should Markdown stop and the diagram renderer begin?

The pipeline

The current pipeline has one responsibility at each stage:

Merender diagram...

remark-gfm adds tables, task lists, strikethrough, and other GitHub-flavored Markdown features. rehype-sanitize removes unsafe HTML before the output reaches React.

rehype-highlight tokenizes normal code blocks. Mermaid is listed as plain text because its source must be passed unchanged to Mermaid, not wrapped in highlighting spans.

<ReactMarkdown
  components={{ pre: MarkdownPre }}
  rehypePlugins={[
    rehypeSanitize,
    [rehypeHighlight, { plainText: ["mermaid", "text", "txt"] }],
  ]}
  remarkPlugins={[remarkGfm]}
>
  {source}
</ReactMarkdown>

Raw HTML remains disabled by default. This matters because portfolio content is still input, even when it lives in the same repository as the application.

Detect Mermaid at the pre boundary

A fenced block becomes <pre><code class="language-mermaid">...</code></pre>. Replacing only the code element would leave the diagram inside a pre, inheriting code-block spacing and overflow.

I therefore inspect the child at the pre boundary. Mermaid blocks return a component; every other language returns the original pre element.

function MarkdownPre({ children, ...props }: PreProps) {
  const child = Children.toArray(children)[0];

  if (isValidElement<CodeProps>(child)) {
    const classes = (child.props.className ?? "").split(/\s+/);

    if (classes.includes("language-mermaid")) {
      const chart = String(child.props.children ?? "").replace(/\n$/, "");
      return <MermaidDiagram chart={chart} />;
    }
  }

  return <pre {...props}>{children}</pre>;
}

This is a small boundary, but it prevents several styling bugs. Normal code remains compatible with GitHub Markdown CSS, while diagrams get a container designed for SVG.

Load Mermaid only when the browser needs it

Mermaid is much larger than the Markdown renderer and depends on browser APIs. Importing it at module load would add work to pages that contain no diagram and complicate server rendering.

The diagram component imports Mermaid inside an effect. A module-level promise ensures the library is loaded and initialized only once.

let mermaidPromise: Promise<MermaidApi> | undefined;

function loadMermaid() {
  mermaidPromise ??= import("mermaid").then(({ default: mermaid }) => {
    mermaid.initialize({
      securityLevel: "strict",
      startOnLoad: false,
      suppressErrorRendering: true,
      theme: "base",
    });
    return mermaid;
  });

  return mermaidPromise;
}

Each render receives a unique ID. The returned SVG is inserted into a dedicated container, and Mermaid's optional binding function is called after insertion.

The effect also tracks whether the component is still active. If navigation removes the component before rendering finishes, the result is discarded instead of writing into a stale element.

Keep failure visible

A documentation renderer should not hide invalid source. When Mermaid throws, the component shows a collapsed error with the original chart.

Merender diagram...

This fallback matters during writing. A typo remains local to one diagram and the surrounding article can still render.

Styling without fighting the SVG

The article uses github-markdown-css for familiar document spacing and a Highlight.js GitHub theme for code tokens. Both are overridden only where the site's IBM Plex fonts should apply.

Mermaid needs separate treatment. The wrapper controls padding, border, and horizontal overflow. Scoping the rule under .markdown-body also overrides the horizontal figure margin from github-markdown-css; otherwise, a full-width diagram is shifted beyond the article on small screens. The SVG follows the available width while preserving its aspect ratio.

.markdown-body .mermaid-wrapper {
  width: 100%;
  margin: 2rem 0;
  overflow-x: auto;
  overflow-y: hidden;
}

.markdown-body .mermaid-diagram {
  min-width: 0;
  width: 100%;
}

.markdown-body .mermaid-diagram svg {
  display: block;
  width: 100%;
  max-width: 100%;
  height: auto;
  margin-inline: auto;
}

I also set Mermaid theme variables explicitly. ER diagrams required rowOdd and rowEven; similarly named attribute variables did not affect the renderer version used by the project.

That detail was a useful reminder: inspect the generated SVG and renderer source before adding more CSS overrides.

What I would keep from this design

The Markdown renderer does not know how Mermaid draws a diagram. It only recognizes one language class and delegates the source.

The Mermaid component does not parse Markdown. It receives a chart, owns the browser lifecycle, and reports either SVG or an error.

That separation keeps both paths ordinary. Code highlighting still works for TypeScript and other languages, while Mermaid can evolve without replacing the Markdown pipeline.

The main lesson is not a specific plugin order. It is to choose one explicit handoff point, preserve the default path, and make failures inspectable.

metadata
published
2026-08-01
topic
ReactMarkdownMermaid
read time
5 min
Related