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.
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:
<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:
What this buys
The trade is worth restating, because it is easy to lose sight of what the constraint is actually protecting.
| Static prose | Client demo | |
|---|---|---|
| In the HTML file | yes | placeholder only |
| Readable with JS off | yes | no |
| Indexed by search engines | yes | not relevant |
| Cost to a reader who scrolls past | zero | zero, 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.



