Vanessa Depraute

Agentic AI Engineer & Senior Full-Stack Developer

tap the underlined words !
โ† All notes
3 min read

Interactive demos that survive static rendering

Static HTML and live WebGL demos pull in opposite directions. Here is the boundary I settled on, and the one rule that keeps both sides happy.

  • #react
  • #ssg
  • #webgl
  • #mdx

Every article on this site is written in MDX and pre-rendered to static HTML at build time. That is good for readers and good for search engines: the text is in the file before any JavaScript runs.

It is also, on the face of it, incompatible with the thing I actually want to publish. A tutorial about easing curves is not worth much without a curve you can poke at. A shader post needs the shader running.

So the two requirements pull apart:

  • Prose must be in the HTML. Rendered once, at build time, in Node.
  • Demos cannot be. They need a canvas, a GPU, a pointer, an animation frame.

What breaks, exactly

During the build, React renders each page with renderToString. There is no window, no document, no requestAnimationFrame. Anything that reaches for them while rendering does not degrade gracefully - it throws, and it takes the whole build down with it.

The subtler failure is worse, because it is silent:

// ๐Ÿ“– Looks harmless. Runs during render. Breaks the build.
const [isDark, setIsDark] = useState(() =>
  document.documentElement.classList.contains('dark-mode'),
);

And even code that does not crash can still be wrong. Anything non-deterministic - Math.random(), new Date() - produces one value in Node and a different one in the browser, so the markup React hydrates disagrees with the markup it finds. React patches it up and warns, and the page flickers.

The boundary

The rule I ended up with is a single sentence:

Prose renders on the server. Demos mount in the browser. Nothing straddles the line.

Every demo goes through one wrapper that enforces it:

an-article.mdx
<Demo
  title="Easing curves"
  height={340}
  load={() => import('@/content/demos/EasingPlayground')}
/>

That wrapper does three things worth spelling out.

It reserves the space before the demo exists. The stage has a fixed height, so the placeholder in the static HTML occupies exactly the same box as the live demo will. Nothing moves when the demo mounts, and the layout in the pre-rendered file is already the final one.

It defers the mount. The real component is only constructed once we are in a browser. Node never sees the WebGL context, so it never has the chance to complain about it.

It splits the bundle. load is a dynamic import, so Three.js sits in its own chunk. A reader who bounces off the introduction never pays for a library they never saw.

Here is the result, embedded in this article the same way you would embed any other component:

Easing curves
Interactive demo

What this buys

The trade is worth restating, because it is easy to lose sight of what the constraint is actually protecting.

Static proseClient demo
In the HTML fileyesplaceholder only
Readable with JS offyesno
Indexed by search enginesyesnot relevant
Cost to a reader who scrolls pastzerozero, it never loads

The demo being absent from the HTML is not a loss. Nobody is trying to index an easing curve. What matters is that its absence costs nothing: the words around it are all there, the page is laid out correctly, and the interactive part arrives when and only when someone can actually use it.