There is a specific kind of delight that comes from typing code and watching a result appear before you lift your hands from the keyboard. No build step. No server round-trip. No progress spinner. The feedback loop closes in under a hundred milliseconds and your brain starts to feel like it is talking directly to the machine.
That feeling is what a playground is actually selling. The visible feature is the editor and the preview pane. The real product is the latency collapse.
I built mine with no backend, no cloud sandbox service, and no build daemon. Every transformation — from the TypeScript you type to the React component you see — happens inside the browser tab. When you close the page, nothing persists on a server anywhere.
The interesting engineering problem is that browsers are not Node.js. Running arbitrary
TypeScript/React code in a browser requires assembling four low-level browser primitives
that almost never appear together in tutorials. This post is the reference I wish had
existed before I started. It documents exactly what each primitive does, why it exists,
and how the four of them interlock.
Three Reasons Browsers Reject Your JSX
Before I describe the solution, it is worth being precise about what the problem actually is. There are three separate failures, not one. Conflating them leads you to grab the wrong tool.
Problem one: the syntax. Paste a .tsx file directly into a <script> tag and the
browser throws a SyntaxError before it executes a single line. TypeScript's type
annotations — const count: number = 0 — are not JavaScript. JSX — <App /> — is not
JavaScript. The browser's JavaScript engine has never been updated to understand either
of them because they were deliberately designed to require a compilation step before
deployment.
Problem two: the imports. After you strip the TypeScript and transpile the JSX to
React.createElement calls, you are left with valid ES module JavaScript. Except for
the import statements:
Browsers resolve module specifiers as URLs. "react" is not a URL. It is a bare
specifier — a package name that Node.js knows how to resolve by walking up the file
system to find node_modules/react/index.js. The browser has no node_modules. It has
no concept of a package registry. When you try to import a bare specifier in a browser,
you get a TypeError: Failed to resolve module specifier "react" at runtime.
The relative "./App" import looks like it should work — it is a relative path, not a
bare specifier. But it doesn't work either. Relative imports are resolved against the
URL of the importing file. If your file lives at a blob URL
(blob:null/abc-123...), a relative import would try to resolve against
blob:null/abc-, which is meaningless.
Problem three: the isolation. Suppose you solve the first two problems and get user
code running. Now it has full access to the host page: your document, your
localStorage, your cookies, your session state. Any user script running in your origin
can read or modify anything on the page.
These are three distinct problems and they require three distinct solutions:
| Problem | Solution | Browser primitive |
|---|---|---|
| Syntax (TSX is not JS) | Babel standalone | In-browser compiler |
| Imports (bare specifiers fail) | Import maps + blob URLs | Module resolution |
| Isolation (user code = untrusted) | srcdoc iframe | Null-origin sandbox |
What makes the playground architecture interesting is that the solutions are not independent — each one creates a constraint that shapes the next.
The Pipeline
Here is the full transformation, from keypress to rendered component:
Each stage is independent. Stage 1 knows nothing about stage 6. You can understand and test each one in isolation. The coupling between stages flows in exactly one direction — later stages consume the output of earlier ones.
The only async work is a one-time initialization call for the lexer WASM binary, which I fire at module load time. Every subsequent build is synchronous.
Babel Standalone: The Browser's Compiler
@babel/standalone is the full Babel compiler packaged as a single browser-ready
bundle. It ships all the same transforms as the Node.js Babel packages — including the
TypeScript and React presets — but bundles them into a format that loads directly in a
<script> tag. No Node.js, no webpack, no build tool required.
It is large. The full distribution is around 1.3 MB gzipped. For a playground that only needs React and TypeScript transforms, you pay that cost once on first load, and then Babel lives in memory. Transforms for typical playground-sized files (a few hundred lines each) complete in single-digit milliseconds.
I configure two presets:
Three decisions here are non-obvious and each one matters.
Presets apply right-to-left. This is Babel convention. TypeScript runs first
(stripping annotations), then React (transforming JSX). If the order were reversed,
the React preset would see TypeScript syntax it doesn't understand. The push order in
the array produces ["react", "typescript"] which means TypeScript runs first.
runtime: "automatic" changes what the React JSX transform emits. With the
classical runtime, <App /> becomes React.createElement(App, null) — which requires
React to be in scope. With the automatic runtime it becomes
import { jsx } from "react/jsx-runtime" at the top of the file. This matters because
those auto-generated imports (react/jsx-runtime) flow through my import rewriting
pipeline just like any other import. Users don't need to manually import React at the
top of every file.
retainLines: true tells Babel not to collapse multiple source lines onto fewer
output lines. Consider what happens without it: a syntax error on line 47 of the user's
code might report itself as an error on line 3 of the transpiled output, because Babel
compressed everything. With retainLines, each output line corresponds to exactly the
same line number in the input. When an uncaught error fires window.onerror inside the
iframe with a line number, that line number points back to the user's original code.
CSS and JSON files skip Babel entirely. CSS becomes a JavaScript module that injects a
<style> tag (keyed by a deterministic ID so re-runs don't accumulate duplicate
sheets). JSON becomes export default <parsed value>.
Here is a React/TypeScript component you can experiment with right now.
Try this: Change step = 1 to step = 5 in App.tsx, then switch to main.tsx and change step={2} to step={10}. Notice there is no import React — the automatic runtime handles it.
The playground transpiles your edits with Babel on every keystroke and renders them in a sandboxed iframe — the full pipeline you'll see below, running live.
The Import Problem
After Babel runs, the code is syntactically valid JavaScript. The import statements are still wrong, but they are at least parseable. Let us be precise about what “wrong” means.
A browser loading a module script resolves specifiers in one of two ways: absolute URL,
or relative path (resolved against the module's own URL). That is the entire list.
"react", "framer-motion", "@tanstack/react-query" — none of these are URLs or
relative paths. They are bare specifiers, and bare specifiers produce an immediate
TypeError with no fallback.
The relative import problem is subtler. "./App" looks like a relative path. In a real
file system it would resolve correctly. But my transpiled files are not real files on
disk. They are strings. I am going to put them into blob URLs in a moment, and a blob
URL looks like blob:null/8a3f1b2c-.... There is no meaningful “directory” for a
relative path to be relative to. The browser cannot traverse a virtual file tree by
following ../utils/helpers from a blob URL.
So I cannot use bare specifiers, and I cannot use relative paths. What can I use?
Import Maps
The answer is import maps. Import maps are a browser-native mechanism — part of the HTML living standard — for telling the module loader how to resolve specifiers. They look like this:
With this map in the page, import { useState } from "react" resolves to the esm.sh
URL. import { App } from "td-playground/src/App.tsx" resolves to the blob URL. The
bare specifier TypeError disappears.
There is one hard constraint: the import map must appear before any <script type="module"> that uses the specifiers it covers. The browser builds the import map
registry before any module scripts execute. If a module script starts loading before the
map is parsed, specifiers that should be in the map will fail to resolve. The ordering
in the generated HTML is therefore: importmap first, then the entry module script.
Import maps handle the npm packages (via esm.sh) and the local files (via blob URLs).
But there is a gap: the transpiled files still contain the original import specifiers —
"./App", "react". I need to rewrite them to the form the import map expects
before I create the blob URLs. That is what the next two tools handle.
The Virtual Namespace
Here is the key insight of the architecture. I need a stable, unambiguous way to refer to each file in the workspace from any other file. The specifier I use in the rewritten import must match the key in the import map.
I cannot use the file's actual path (/src/App.tsx) as an import specifier, because
import maps require specifiers to be either URLs or valid module specifier strings. A
bare /src/App.tsx would be treated as a URL-relative-path from the page's origin —
not what I want.
I cannot use a data URL or pre-computed blob URL as the specifier, because I need to rewrite the import specifiers before I create the blob URLs (the creation order must be reversed).
The solution is a virtual namespace: a made-up bare specifier prefix that I own.
When I rewrite imports, every local file reference becomes a td-playground/...
specifier. When I build the import map, every td-playground/... key maps to the blob
URL for that file. The map entry is constructed last, after all blob URLs exist.
The virtual prefix also solves a secondary problem: it is unambiguous. A specifier
starting with td-playground/ cannot be confused with an npm package, a URL, or a
relative path. The import rewriter can pattern-match on the prefix to determine exactly
what kind of specifier it is dealing with.
Here is a multi-file workspace where you can see cross-file imports working.
Try this: Open utils.ts and change the formatBytes thresholds from 1_000_000 and 1_000 to 1_000_000_000 and 1_000_000. Watch App.tsx pick up the change through the virtual namespace — no manual reload, no build step.
App.tsx imports from ./utils, which becomes td-playground/src/utils.ts in the
rewritten source and maps to the blob URL for utils.ts in the import map. The
resolution chain works because every file in the workspace has exactly one entry in the
import map.
es-module-lexer
I need to find every import specifier in a file and replace it. The temptation is to use a regular expression. Resist it.
Import specifiers appear in patterns like:
But they also do not appear in contexts that look similar:
A regex that matches from\s+["']([^"']+)["'] will produce false positives in all
three of those cases. The gap between “works on my test cases” and “works on all valid
JavaScript” is exactly the gap between regex and parsing:
es-module-lexer is a WebAssembly binary that scans ES module source and returns the character offsets of every import and export statement. It does not build an AST. It does not understand the semantics of the code. It only finds the boundaries.
s and e are character offsets into the source string, pointing to the content of
the specifier (the text inside the quotes). n is the resolved specifier string. With
these offsets I can replace any specifier surgically — without touching any other
character in the source — using magic-string.
The WASM binary is small (~36 KB) and parses faster than any JavaScript alternative
because the main loop is native machine code. I initialize it once at module load time
so the async init promise is settled before the first build request arrives.
magic-string
Once I have character offsets, I need a tool that can replace substrings at specific positions in a string without invalidating the positions of subsequent replacements.
You could do this with a naive string substitution, but you have to be careful: if you replace a 5-character specifier with a 47-character URL, every offset after that position shifts by 42. If you apply replacements front-to-back, you have to adjust all subsequent offsets. If you apply them back-to-front, you can avoid the adjustment — but back-to-front is fragile and the logic gets ugly quickly.
magic-string is a library designed exactly for this problem. It wraps a string and tracks a sequence of mutations. Offsets always refer to the original string, so you can apply all replacements in any order without offset arithmetic:
magic-string tracks all mutations internally and applies them correctly when you call
toString(). It also maintains a source map of every character rewrite, which is useful
if you ever want to generate proper source maps. I do not emit source maps (I use
retainLines instead, which is simpler), but magic-string's origin in the Rollup
ecosystem means the source map capability comes for free.
Blob URLs
With each file transpiled and its imports rewritten to virtual specifiers, I have a collection of JavaScript strings. I need to turn them into something the browser can load as a module.
URL.createObjectURL converts an in-memory Blob into a blob: URL:
The browser treats this URL as a real resource. You can use it anywhere you would use
an HTTP URL: in an import map, in a <script src>, in a fetch request. Blob URLs are
same-origin with the page that created them (or null-origin for opaque origins, which
is what I have in the srcdoc iframe).
Each file in the workspace gets its own blob URL. I collect them all into the import
map alongside the esm.sh URLs for npm packages:
Memory management. Blob URLs are reference-counted. The browser will not garbage
collect the underlying data until you call URL.revokeObjectURL(url). A playground
that rebuilds on every keystroke can create dozens of blob URLs per minute if it leaks
them. I track every blob URL I create in a ref, and before each new build I revoke
the previous set:
The effect cleanup also revokes on unmount, so navigating away from the page does not leak blob URLs into the browser's memory.
The iframe Sandbox
The transpiled, import-rewritten, blob-URL-resolved workspace is now ready to render.
I inject it into an <iframe srcdoc="..."> element.
srcdoc lets you set the entire HTML document of the iframe as a string attribute. No
network request, no URL, no server. The iframe loads its document from the string I
provide. The generated HTML looks like:
There are several non-obvious constraints in this structure.
Import map ordering. The <script type="importmap"> must come before any
<script type="module"> that references the mapped specifiers. If the module script
begins executing before the import map is parsed, bare specifiers will fail. I always
emit the importmap first.
The sandbox attribute trap. Your first instinct might be to add
sandbox="allow-scripts" to the iframe for security. Do not do this. The sandbox
attribute, without allow-same-origin, strips the iframe of its origin and gives it a
unique opaque origin. Blob URLs created outside the iframe — which is where I create
them — are same-origin with the outer page. An opaque-origin iframe cannot load
same-origin blob URLs. They fail silently with a CORS-like error. If you want to use
sandbox, you must also include allow-same-origin, which largely defeats the purpose
of sandboxing.
I do not set sandbox at all. Instead, I rely on the fact that srcdoc iframes
have a null origin by default — they are not same-origin with the host page. User
scripts inside the iframe cannot access window.parent's DOM, localStorage, or cookies
via the same-origin policy. The isolation is automatic and free.
The </script> escaping trick. The import map and error handler JavaScript are
embedded directly in the srcdoc HTML string. If that JavaScript contains the literal
string </script>, the HTML parser will think the script element has ended prematurely.
I escape it:
This is called on both the import map JSON and the error handler source before embedding them in the srcdoc string.
Here is a component that runs inside the sandboxed iframe and tries to reach the host page.
Try this: Click “Attempt escape” and watch what happens. Then try modifying the code to access window.parent.localStorage or document.cookie instead.
The null origin boundary enforces isolation without any sandbox attribute —
and without any of the sandbox attribute's footguns.
Error Handling
A playground that silently fails on user errors is frustrating. I need the iframe to communicate errors back to the host page.
The error strategy has two layers: Babel catches syntax errors before the code runs,
and window.onerror catches runtime errors after it does. Both communicate back to
the host page via postMessage:
The channel: "td-playground" field namespaces the messages from any other postMessage
traffic on the page. (I learned this the hard way — see “Surprises” below.)
The retainLines: true Babel option is what makes the err.loc?.line useful. Without
it, Babel compresses a 50-line component into 3 lines, and error line numbers point
into the compressed output instead of the user's source.
Monaco and Multi-File Types
The editor is Monaco — the same engine that powers VS Code. I load it via
next/dynamic with ssr: false. Monaco is a substantial bundle and it has no
server-side rendering path; loading it dynamically keeps it out of the initial
page payload.
Each file in the workspace gets its own Monaco model, identified by a URI:
Switching between file tabs does not unmount and remount the Monaco component. Instead
it calls editor.setModel(model) to swap the active model. This preserves each file's
independent undo/redo history, scroll position, and cursor location. Remounting would
reset all of these, and users would notice.
The multi-file TypeScript language service is the best part. Because all models are
registered with the same Monaco instance and all use URIs in the file:// scheme,
Monaco's TypeScript language server can resolve cross-file imports automatically. When
App.tsx contains import { formatBytes } from "./utils", TypeScript resolves it to
the model at file:///src/utils.ts and provides type checking, autocompletion, and
go-to-definition across the workspace. No additional configuration — it works by
convention of the URI scheme.
The Vesper dark theme I apply is an intentional design decision. Playgrounds embedded
in articles are competing with the article text for attention. A neutral dark theme with
muted syntax highlighting keeps the focus on the structure of the code rather than the
colors. The theme data is loaded from a static JSON file and registered with Monaco's
defineTheme API before the first render.
Storage: The Coercion Pattern
The playground's state survives page refreshes via localStorage. The interesting
design decision is not the storage schema — it is how reads are handled.
Every read from localStorage passes through a coercion function:
The principle: never trust stored data. localStorage is a time capsule from a
previous version of your app. Fields get added, renamed, removed. Data written by
version 1 will be read by version 7.
The coercion function validates and normalizes the stored JSON against the current schema. Missing fields get sensible defaults. Changed shapes get coerced. Malformed data gets repaired, not rejected. This means I can evolve the schema freely — add fields, rename fields, change types — without migration scripts, without version keys, without breaking existing users.
Here is the full IDE experience with multiple files and tabs.
Try this: Open Badge.tsx and change the background color from #1e1e2e to #f38ba8. Then open hooks.ts and change the interval from 1000 to 100. Each file maintains its own undo stack — press Cmd-Z in hooks.ts to confirm.
Surprises and Mistakes
What I got wrong the first time. My first implementation used sandbox="allow-scripts" on the iframe and I spent three hours debugging why import maps
stopped working. The spec is clear on this: without allow-same-origin, blob URLs from
the parent origin are inaccessible. I removed sandbox entirely and the isolation I
cared about (null origin) was already there.
What surprised me. Monaco's cross-file TypeScript works without any configuration. I expected to write glue code to feed cross-file type information to the language server. I didn't need to. The URI convention handles it automatically. This is the kind of surprise you only get by trying something rather than reading about it.
The postMessage naming collision. The first time I deployed the playground to a
page that also used an analytics library, I got phantom error messages populating the
error panel. The analytics library was posting messages to the window. I had not
namespaced my listener. Adding the channel: "td-playground" filter field fixed it
in one line.
The retainLines discovery. I shipped without retainLines for the first two
weeks. Error line numbers were useless. Users reported that the error panel showed
“line 2” for errors that were clearly on line 40. Adding retainLines: true fixed the
correspondence immediately. It costs a little output size (more newlines preserved) but
that cost is irrelevant for in-memory strings.
The spec as documentation. Every unintuitive behavior I encountered — import map
ordering, sandbox and null origins, blob URL same-origin rules — was explained
correctly and in detail in the HTML living standard. Browser vendor documentation
(MDN, Chrome DevTools docs) explained what the APIs do. The spec explained why they
behave the way they do when the behavior was surprising. For low-level browser API
work, the spec is the fastest path to understanding.
Ideas I Haven't Built Yet
Worker-based transpilation. Babel runs on the main thread. For playground-sized files this is imperceptible, but a single large file (thousands of lines, many dependencies) can block the event loop for tens of milliseconds. Moving Babel to a Web Worker would keep the editor fully responsive and would let me cancel in-flight transpilation when the user types faster than Babel can keep up.
Shareable links via URL hash. Encode the workspace as a compressed base64 blob in the URL fragment. No backend required. The main constraint is URL length: LZ-string compression typically gets a medium-sized workspace (a few files, a few hundred lines each) under 2000 characters, which fits comfortably in a URL. Longer workspaces would need a backend link-shortener or a different encoding strategy.
Server-side pre-build for recipe articles. In recipe articles, each step's workspace
is static and known at Next.js build time. I could pre-transpile every step's files,
pre-compute the import map, and hydrate the client with already-resolved blob URLs.
This would eliminate the first-load Babel pass entirely for recipe readers. It is more
infrastructure to maintain, but it would make the first-render of embedded playgrounds
instantaneous.
The architecture has held up well to the recipes feature, which places four to six independent playgrounds on a single page. Each playground is a genuine island: separate iframe, separate blob URL namespace, blob URLs cleaned up between re-renders. Editing one playground's code has no effect on any other. The isolation boundary that I designed for security turns out to be equally valuable for correctness.
The core takeaway from building this: the browser platform is more capable than most developers assume. Import maps, blob URLs, srcdoc iframes, and ES module loading are all documented in the living HTML standard. They work in every modern browser. You do not need a cloud function or a build daemon to run a TypeScript/React playground in the browser. You need four primitives and the patience to read their specifications carefully.