How We Replaced StackBlitz and CodeSandbox With Our Own Demo Runner
Why we built demos.handsontable.com, how a two-tier runtime works behind a single adapter, and everything that broke along the way.
If you have browsed the Handsontable documentation over the past few years, you have clicked an “Open in StackBlitz” or “Edit in CodeSandbox” button. Hundreds of our docs examples relied on third-party sandboxes to give you a live, editable playground. It mostly worked. It also cost us money every month, tied a core part of our documentation to infrastructure we did not control, and — worst of all — gave us no reliable way to control which version of Handsontable an example actually ran.
So we built our own demo runner at demos.handsontable.com, and this post is the story of how it works and what we learned killing the dependency.
The problem
Third-party sandboxes for docs examples come with three costs that compound over time.
Recurring cost. Every “open this example” click was a billable event on someone else’s platform. For a documentation site with hundreds of live examples across a dozen framework integrations, that adds up, and the bill scales with our traffic, not with our value.
An external runtime dependency in the middle of our docs. When a sandbox provider changes their embed API, has an outage, or deprecates a template format, our documentation breaks — and we find out from user reports, not from our own monitoring. At one point we introduced a render-ms redirect service purely as a band-aid to keep example links stable while the underlying sandbox targets shifted underneath them. A redirect layer whose only job is to absorb someone else’s churn is a pretty clear signal that the architecture is wrong.
No version control. We ship a major version of Handsontable fairly often, and maintain framework wrappers for React, Angular, Vue, and more. A docs example needs to run the exact handsontable core version and the exact wrapper version that the surrounding docs page describes. With external sandboxes, pinning that reliably across hundreds of examples and every framework template was fragile at best. Examples drifted. Users copy-pasted code that behaved differently from the page explaining it.
The idea
The goal we set was simple to state:
- One authoring UX for every example, regardless of framework.
- Any Handsontable version on the fly: an example should be able to run major 15 through 19, a specific patch, or even an unreleased build from a PR.
- Permanent, revocable share links that we own.
- Docs embeds locked to our own domain, so an example cannot be lifted and rehosted with our compute behind it.
The result is a runner where every example is described once, and the platform decides how to execute it.
The architecture
The core abstraction is a DemoRuntime adapter with two engines behind it. The authoring experience, the share links, and the embed format are identical; only the execution strategy differs.

Tier 1: in-browser bundling with Sandpack
For the majority of examples (currently seven templates covering vanilla JS and the lighter framework setups) we use Sandpack, the open-source in-browser bundler. The example bundles and runs entirely in the visitor’s browser tab.
This tier is instant and has zero per-view cost. No container spins up, no server renders anything. For a docs site where most example views are quick “let me see this feature in action” visits, this is exactly the right trade.
Tier 2: real dev servers in Cloudflare Sandbox containers
Some frameworks cannot be faithfully emulated in a browser bundler — SSR frameworks in particular. For six templates (Angular, Next.js, next-shadcn, Astro, Nuxt, and Remix), the runner boots a Cloudflare Sandbox container running the framework’s actual dev server, HMR included. When you edit the example, you are editing against the real toolchain, not an approximation of it.
This tier costs money per session, which is why the split matters: Tier 2 is reserved for the cases where fidelity genuinely requires it, and everything else stays on the free tier of physics.
Version dispatch
Every example declares a Handsontable version, and the runner pins the core handsontable package and the framework wrapper in lockstep. The dispatch layer accepts semver ranges, npm partials, and even pkg.pr.new builds — meaning an example can run against a package built from an open pull request before it is ever published to npm. We currently support majors 15 through 19.
This is the feature that external sandboxes could never give us reliably: the guarantee that the example on a docs page runs the version that page documents.
Static prebuilt shares
Share links needed one more optimization. When you share a client-side example, the runner builds it once, stores the output in R2, and serves it from a short /d/:id URL. Viewers of a shared demo never spin up a container and never re-bundle — they get static, prebuilt output. Shares are permanent until revoked, and revocation is ours to control.
Anyone can open an example, edit it and watch it run. Signing in adds one thing: you can fork what you are looking at, give it a title, and save it as a demo of your own with a permanent link. This enables our support team to send live, working demos, instead of screenshots, and customize them for any version or framework.
Docs embeds
Docs pages embed examples via /embed/:id, with a frame-ancestors policy locked to handsontable.com. Our compute serves our docs, and only our docs.
Every example in the documentation
The starter templates are the visible half. The larger half is the documentation: every example in every guide is imported into the runner (1,452 of them at the last regeneration, across five frameworks and 125 guide paths) so any snippet in the docs can be opened as a live, editable demo rather than a code block you copy and hope.

They are generated, not hand-maintained. A pipeline walks the docs source, finds every example block, wraps each one the way the docs’ own “edit this” button used to, and commits the result as a snapshot. Regenerating is one command, which is critical for docs that move constantly. Anything requiring manual upkeep would have rotted inside a month.
The stack
The whole thing runs on Cloudflare Workers, with D1, KV, and R2 for state, metadata, and build artifacts. Authoring access goes through a Google-broker login restricted to @handsontable.com accounts.
What broke
This is the section that would be missing from the marketing version of this post. Migrating hundreds of live examples onto infrastructure you just built is a bug-discovery machine, and some of what it discovered was in our own product.
Babel 6.26 choking on optional chaining. Our vanilla-JS runtime initially shipped with an ancient Babel that predates optional chaining. Modern example code using ?. failed to transpile at all. Obvious in hindsight; invisible until the first migrated example hit it.
getPlugin() returning undefined in React examples. Several React examples calling getPlugin() for newer plugins got undefined back. Tracking this down in the runner context surfaced a real integration issue rather than a runner bug — the kind of latent problem that only shows up when you execute every example on every supported version.
Angular builds failing on type paths. Angular’s stricter build pipeline failed on deep, non-exported HyperFormula type paths and on numbro typings. External sandboxes had been quietly tolerant of these; our own toolchain was not. Fixing them made the types better for everyone, not just the runner.
Per-version manifest buckets breaking the link sweep. Splitting example manifests into per-version buckets was the right structural call, but it broke the automated test that sweeps every docs link to verify examples load — the sweep did not know about the new bucket layout until we taught it.
Blank grids found by sweeping everything. Speaking of the sweep: we ran an automated pass over every live example link in the docs and caught a set of examples that loaded but rendered no grid at all. Some had presumably been broken on the external sandboxes for a while, silently. Owning the runtime meant we could finally test it end to end.
The pattern across all of these: third-party sandboxes were hiding failures from us. Some by being tolerant of things they should not have been, some by simply being unobservable. Bringing execution in-house converted unknown breakage into a bug list — which is exactly what you want, even when the list is long.
Results
- Cost: per-view sandbox spend replaced by a Workers stack where the majority tier costs effectively nothing per view, and the expensive tier is used only where fidelity demands it.
- Control: no external runtime in the docs critical path. When something breaks, our own monitoring catches it and we fix it ourselves.
- Correctness: every example runs a pinned, real version of Handsontable and its wrapper, including pre-release builds when we need them.
- UX: docs examples now carry an “Open in runner” action everywhere the sandbox buttons used to be — and so does the Theme Builder, whose Open in playground button replaced Open in StackBlitz and hands its generated project straight over, theme already applied.

And because the post should eat its own dog food, here is a live example, embedded from the runner itself, not a screenshot:
And a Tier 2 example running a real framework dev server:
What it costs, and what stops it running away
Moving off someone else’s per-view billing is only an improvement if you know what your own infrastructure costs. Thirty days of real traffic, read off the panel as this post went out: $3.26 against a $1,000 ceiling, across 2,027 sessions and 25 builds. The split is the interesting part, and it has already moved: containers $0.89, the AI assistant $2.31, Workers requests $0.03, egress $0.02. The expensive tier is no longer the expensive line — answering questions is. Tier 1 and the static shares round to nothing, which is the whole point of pushing most views down a tier.
The uncomfortable discovery came next: Cloudflare has no hard spend cap on Workers Paid. Its budget alerts email you a projection; they stop nothing. The one real cap is max_instances, and it only bounds containers — five live sessions plus three builders tops out somewhere around $250–460 a month even if every slot ran flat out, around the clock. Egress, requests and logs are unbounded, and the endpoint that starts a session is public.
So the ceiling had to be ours. A cost ledger meters container awake-seconds, proxied egress and requests, and a nightly job replaces those estimates with Cloudflare’s own billing analytics. Crossing a threshold degrades the product in stages instead of falling over: first a notice, then live editing requires a sign-in, then no new sessions start, then running ones are torn down. Static shares and docs embeds are never gated at any tier — they are object-storage reads, they cost nothing to serve, and they are what most visitors actually came for.
The counter-intuitive lesson: full-fidelity logging is a spike amplifier. Workers Logs bills per event, so the traffic surge you most want to survive cheaply is precisely when your observability bill is largest. Ten percent head sampling fixed that, and Sentry keeps the faults.
Chat with the example
The playground now has an Ask AI panel scoped to whatever is open in the editor. Ask what the example does, what an option means, or ask for a change — and the change comes back as an edit you can apply, which streams into the running preview through the same path your own keystrokes take. You watch the answer run rather than reading a description of it.
Answers are grounded in two different searches, because they do different jobs. Our documentation assistant returns real content chunks from a vector index — slow, a couple of seconds, but it is what stops a model inventing API surface. Algolia returns headings and URLs in about fifty milliseconds — no content, but it is what tells you which page to read next. Both are filtered to the example’s own framework, because every guide exists once per framework and an unfiltered search returns four copies of the same page.
One constraint shaped the design, and it belongs in the “what broke” column: Cloudflare blocks Worker-to-workers.dev requests outright, and the documentation assistant lives there. Our backend simply cannot call it. So retrieval runs in the browser and the model call runs in the Worker, where the API key can stay secret — a split the platform forced, not one we designed.
Nothing is applied on its own. The panel shows which files an answer would change; you press Apply, and one press of Undo puts your code back. Each answer costs about a cent, and it is metered into the same ledger as everything else.
Style it like Theme Builder
The second panel is a theme editor. Handsontable already has a Theme Builder, and it is good — but it styles its own demo grid, and getting the result into your code is a copy-paste at the end. In the runner the same controls point at the example you have open.
It carries the whole token catalogue rather than a curated subset: 272 tokens, five common sections and eighteen components, each token rendered as the control it deserves. A font family is a dropdown of the families that exist, not a text box you have to spell correctly. A size shows what it resolves to — 4px, not sizing.size_1 — and can point at the sizing scale, follow the density preset, or take a literal. A colour opens the palette it is allowed to draw from. Anything you override gets a reset that appears only once there is something to reset.

What makes it more than a port is where the theme goes. It is written into the example as a real module (handsontable-theme.ts), built with Handsontable’s JavaScript theme API — through the same file-write path the editor and the assistant use. So it appears in the file tree, hot-reloads into the preview, and travels with a Download or a Share. The theme is just another file in the demo.
Getting there meant solving the problem that makes a multi-framework playground different from a single demo grid. A theme registered in JS has to be handed to the grid, and the code that constructs the grid is different in every starter: <HotTable theme={…}> in React and Vue, new Handsontable(el, {…}) in plain JS and Astro, a gridSettings object in Angular. A regex loose enough to cover all of them will eventually mangle someone’s component and cost them an afternoon.
So the runner recognises those three shapes specifically and edits nothing else. When an example is built in a shape it does not recognise, it writes the theme module anyway and tells you the one line to add, rather than guessing and corrupting the file. The generated module is the same thing you would paste into a real application, which is the point: registerTheme(), .params({ tokens }), setColorScheme(), setDensityType() — no CSS overrides shadowing a theme, just the theme.
We did try the other way first. Overriding the CSS custom properties needs no component edits at all, and Handsontable’s docs list every token in both forms (CSS: --ht-accent-color beside JS: accentColor), so it looks like the safer route. It was reverted: a stylesheet layered over a registered theme fights the theme rather than replacing it, and the copy-paste you leave with is not what a real application should contain. The wiring problem was worth solving properly.
Ask for a demo instead of building one
The runner started as a place to edit examples. The most-used route into it now does not involve the browser at all: you describe the demo you want to Claude, and a link comes back. It goes through our own MCP server, so there is nothing to install and no folder to prepare.
Load create_demo, then create a demo with an invoice grid
(12 rows, column filters, status dropdown, totals row)
and give me the share link
Claude writes the example — with the Handsontable documentation available to it while it does — posts the files to a service-authenticated endpoint on the runner, and the runner builds them exactly as pressing Save would. What comes back is the same four links any other demo has: the client page, the editor, the read-only playground, the docs embed. The demo lands in the asker’s own My demos, because the MCP asserts the Google identity of the session that called it and can assert nothing else.
One thing we got wrong at first: the runner verifies that a demo builds and publishes — it never opens the result. Our very first MCP-made demo proved why that’s not enough. The build succeeded, the endpoint returned a 200, the share link worked — and the page showed no grid, because one line of generated code hit a runtime error the compiler had no way to see (a grid method called from a callback where
thisisn’t the grid). The lesson stuck: a green build is not a working demo. Open the link before you send it.
That failure is also why there is a second tool. update_demo edits a demo in place — same id, same links — so the fix reaches everyone who already has the link instead of leaving a broken page alive beside a corrected copy. It refuses demos it did not create, and refuses revoked ones outright rather than resurrecting a link somebody deliberately killed.
Load update_demo, then fix the invoice demo you made me —
the page loads but the grid does not appear
Getting demos in from everywhere else
Killing the sandbox dependency does not delete the demos people already made in one. Four routes bring existing work in, and none of them was as simple as copying files across.
- JSFiddle is parsed out of the page it already serves — its three panels are server-rendered into
textareaelements. The interesting part is that copying them is not enough: a fiddle loads Handsontable from a CDN<script>tag, which the bundler cannot see, so the import rewrites those tags into real dependencies. Only then does the imported demo follow the version control like everything else. - StackBlitz keeps its whole project in a Redux snapshot on the edit page; that comes across as-is, minus build output, lockfiles and binaries.
- CodeSandbox we refuse, with instructions. Its API answers
403behind a bot challenge, and defeating that is not something we will do — so a pasted CodeSandbox URL returns “export it to a zip and drop the files instead”. - The Theme Builder needs no URL at all: its Open in playground button uploads the generated project and opens it here, theme applied, as an unsaved workspace that Save turns into a real demo.
The last route is a drag and drop, and it is the one support uses most. Drop files, a folder, or a .zip onto the file panel and they land in the workspace. A zip is unpacked in the browser and never stored: a single wrapping directory is stripped, because archives are project/… and nobody means to land project/src/index.js; .. segments and absolute paths are refused rather than resolved; and every entry then faces the same rules a loose file faces — text only, no .env ever, no node_modules, 512 KB a file. When it rejects a file, the error says which rule it broke.
That closes a loop we did not plan for. Download already handed anyone the whole workspace as a zip, which is how you answer a forum thread with “here is exactly what I ran”. Now the reverse works too: when somebody attaches an archive of the project that fails for them, it is a drag away from running here, on whichever version you want to test.
Nineteen templates, generated per major
The starters were the last hand-maintained thing in the runner, and the least defensible. One scaffold per framework, re-pinned at runtime to whichever major you selected — which works only while the source happens to avoid every newer API. It does not: handsontable/themes exists only from 17.0, the Angular wrapper has no 15.x release at all, and the styling idiom we recommend today is impossible below 17 because nothing injects the core CSS there. One file cannot be simultaneously correct for 15 and idiomatic for 18.
So starters are generated per Handsontable major (15, 16, 17, 18 and next) from the same repository the runner itself lives in, and a CI matrix boots every one of them at every bucket. There are nineteen: three deliberately empty templates (JavaScript, TypeScript, React) for building something up from nothing, and sixteen framework and UI-library combinations. The documentation examples work the same way and are far more numerous: 1,261 for the 18.0 docs branch, 1,452 on next, imported by a workflow rather than copied by hand.
Who looked, without tracking anyone
The same admin panel that meters spend answers the other question a self-hosted service raises: is anyone using it. Thirty days in, the panel reports 2,554 page views from 31 unique visitors, which pages they were, which countries, and how many demos exist (42, five of them revoked).
The panel’s counters are deliberately coarse: daily totals rather than per-visitor records, with unique visitors approximated by a salted hash that is rotated and deleted each day — enough to say how many people came, not enough to recognise the same person on two different days. That loses retention curves, and it was the right trade for a gauge whose only job is answering “is this worth running”. The AI assistant feeds the same counters: a question was asked, against which framework.
The part that is not code
A tool the whole company is supposed to use needs an answer to “where do I start”, and one page for everybody was the wrong shape: sales wanted one sentence typed into Claude, support wanted the browser routes, DevRel wanted embeds, engineers wanted builds from an unreleased PR. So the in-app guide is four role-based tracks — /guide/everyone, /guide/support, /guide/devrel, /guide/developers — each written for one job, with copyable prompts and a link per section so a colleague can be sent to the paragraph rather than the page.
It is rendered from the markdown that lives beside the code, so a pull request that changes behaviour changes the documentation in the same diff. Creating demos is internal — @handsontable.com sign-in, and the link to sign in is deliberately hard to find. Reading one never is: every client link, every embed and every read-only playground is public, which is the entire point of the thing.
Summary
We replaced two third-party sandboxes with a runner we own. One DemoRuntime adapter hides two execution strategies: examples that can bundle in the browser do, and the ones that need a real dev server get a Cloudflare Sandbox container. Authoring, share links and embeds are identical either way, so nothing downstream has to know which engine ran.
What that bought us, in order of how much it mattered:
- Version control over our own examples. Any example, at any Handsontable major from 15 to 19, or at a
pkg.pr.newbuild straight off a pull request. This was the thing we could not do before at any price. - The whole documentation, live. 1,452 guide examples across five frameworks, generated by a pipeline rather than maintained by hand.
- Permanent shares and locked-down embeds. Prebuilt static output from R2, so a shared demo costs a file read rather than a container.
- Two things the sandboxes never offered: an assistant scoped to the example in front of you, and a theme editor that writes a real module into the demo instead of handing you a snippet at the end.
- A cost ceiling we set ourselves, enforced in code, with spend degrading live sessions in stages while static shares keep serving.
The part worth passing on is less flattering. Building the runner did not create most of the breakage we found — it revealed it. Examples had been quietly broken behind sandbox defaults for a long time, and a platform of our own is what made them fail loudly enough to fix. The same pattern held right through the theme editor: of the defects we shot in its final week, most produced no error and no warning, and a UI that looked entirely correct. They lived in the seam between the panel, the file it generates and the grid that reads it — the seam no unit test was watching.
If you maintain a documentation site with live examples on third-party sandboxes, the honest summary is: the migration will surface more breakage than you expect, most of it was already there, and finding it is the point.
Try it at demos.handsontable.com.