
The Phantom Diagrams: How I Ditched Server-Side Headaches for Client-Side Magic

AI Article Summary
You know that moment. You’ve spent weeks building a blog or a portfolio you’re genuinely proud of. You test it locally, and everything looks perfect — animations, diagrams, responsiveness, all silky smooth. You deploy, expecting applause. But instead, you get build logs filled with cryptic errors and dependency hell. That’s exactly what happened to me when I decided to add dynamic Mermaid.js diagrams to my Astro-powered portfolio and blog.
Locally, it was a dream. Beautiful flowcharts and sequence diagrams rendered straight from fenced code blocks like ```mermaid
— all converted into SVGs during the build process using Playwright.
But the second I hit deploy on Vercel?
The dream turned into dependency purgatory.
The Original Idea: Server-Side Rendering with Playwright
The concept was straightforward (in theory): Use Playwright during the build step to convert Mermaid code blocks into static SVGs. This would mean:
- No Mermaid.js bundle on the client
- Diagrams pre-rendered into the final HTML
- No runtime dependency for rendering
So I added Playwright as a dependency, included a postinstall
script to install the necessary browsers, and wrote a script that would scan Markdown content during the Astro build and render diagrams.
"scripts": {
"postinstall": "npx playwright install"
}
This works great on your machine where apt-get
exists and dependencies like libnss3
and libx11
are just a sudo
away. But then Vercel laughed.
The Error Logs That Broke My Soul
Here’s what greeted me on deploy:
[23:21:07.099] sh: line 1: apt-get: command not found
[23:21:07.106] Failed to install browser dependencies
[23:21:07.107] Error: Installation process exited with code: 127
No apt-get
, no system libraries, and absolutely no way for Playwright to run in the sandboxed Vercel environment. I even tried moving things into vercel.json
:
{
"build": {
"env": {
"INSTALL_PLAYWRIGHT_DEPS": "1"
},
"installCommand": "npx playwright install chromium"
}
}
Still, the build failed during the actual execution — Playwright couldn’t launch its headless browser.
Temporary Fix: The Feature Flag Nobody Talks About
I hated doing this — but I disabled the diagram rendering altogether behind a feature flag just to get the blog live. That was my lowest point.
I had the content. I had the code. I had the visuals. But I couldn’t get them past the build system.
So I stopped trying to force it.
The Turning Point: What If the Client Did the Work?
Here’s the shift in mindset:
Why am I bending over backwards trying to make the server render something the client is fully capable of doing?
That’s when I decided to flip the architecture entirely.
Instead of generating diagrams on the server, I’d render them dynamically in the browser using React. And honestly? It worked better than I expected.
Building the MermaidRenderer
React Component
I rewrote the logic as a proper MermaidRenderer.tsx
component. The component does the following:
- Watches for code blocks with
language-mermaid
class. - Dynamically imports Mermaid.js to keep the bundle size lean.
- Renders diagrams using
mermaid.render
. - Injects React buttons into each diagram (copy to clipboard, fullscreen view).
- Listens for DOM changes using
MutationObserver
to handle lazy-loaded content or content rendered later.
Here’s the rough structure:
useEffect(() => {
if (!mermaidInitialized) return;
const renderDiagrams = async () => {
const mermaid = (await import("mermaid")).default;
const blocks = document.querySelectorAll(
"pre code.language-mermaid:not([data-mermaid-rendered])"
);
for (let i = 0; i < blocks.length; i++) {
const code = blocks[i].textContent;
const svg = await mermaid.render(`diagram-${i}`, code);
// Replace <pre><code> with the new SVG and inject buttons
// ...
}
};
renderDiagrams();
const observer = new MutationObserver(/* watch for new content */);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [mermaidInitialized]);
I also used ReactDOM.createRoot
to inject the diagram buttons into each SVG block — buttons for fullscreen mode and clipboard copy.
And yes, the Mermaid theme is styled with my Tokyo Night color palette. 😎
The UX Wins
Switching to client-side rendering gave me more than just a successful deployment:
- No More Build Failures: Vercel now builds flawlessly. My
npm run build
is back to being boring, as it should be. - User Interactivity: I added features like full-screen viewing and instant SVG copy. This would’ve been painful with server-rendered diagrams.
- Performance: Mermaid.js is lazy-loaded. The script doesn’t even load unless there’s a Mermaid diagram present.
- Future-Proofing: With React 19 and its new compiler optimizations, performance is only getting better. This setup already benefits from hooks like
useCallback
andmemo
.
The Architecture in Practice
Now, whenever a blog post has Mermaid code blocks, the following happens:
- The
MermaidRenderer
component activates on the client. - It lazy-loads Mermaid.js.
- It parses the DOM for unrendered Mermaid blocks.
- It renders them into SVGs.
- React buttons (copy, fullscreen) are injected into each SVG container.
- The entire process runs after the page has loaded, without blocking the initial render.
Final Thoughts: When to Stop Fighting the Build System
This whole experience taught me a valuable lesson:
If your server-side setup is fragile, and the browser can handle it — let the browser do it.
Instead of brute-forcing headless rendering with Playwright, I leaned into the strengths of client-side React. Not only did it solve the problem, it made the solution better, faster, and more interactive.
So next time you’re debugging a server-side issue and nothing seems to work, ask yourself:
“Is this really the server’s job?”
You might find your answer — and your sanity — on the client side.
💬 Join the Discussion
Share your thoughts and engage with the community