How I Built a Browser-Native React Playground

// 2026-02-18// engineering// 25 min read
// views

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.

TS
1// This is a SyntaxError from the browser's perspective.
2// The browser has no idea what ": number" means.
3function greet(name: string): string {
4 return <p>Hello, {name}</p>;
5}

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:

JS
1import { useState } from "react";
2import { App } from "./App";

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:

ProblemSolutionBrowser primitive
Syntax (TSX is not JS)Babel standaloneIn-browser compiler
Imports (bare specifiers fail)Import maps + blob URLsModule resolution
Isolation (user code = untrusted)srcdoc iframeNull-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:

playground / pipelinestep 1 of 7
1Source TSX
output
1import { useState } from "react";
2import { App } from "./App";
3
4type Props = { step?: number };
5
6export function Counter({ step = 1 }: Props) {
7 const [count, setCount] = useState(0);
8 return <button onClick={() => setCount(c => c + step)}>{count}</button>;
9}
Step 1. The user types TSX in the editor. The browser cannot execute this — TypeScript annotations are not JavaScript, JSX is not JavaScript, and bare import specifiers are not URLs.

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:

4 annotations
TS
1const result = Babel.transform(code, {
2 presets: [
3 ["react", { runtime: "automatic" }],
4 "typescript",
5 ],
6 plugins: ["transform-modules-commonjs"],
7 filename: "app.tsx",
8});

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.

Setting up the editor...

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:

HTML
1<script type="importmap">
2{
3 "imports": {
4 "react": "https://esm.sh/[email protected]?bundle",
5 "react-dom/client": "https://esm.sh/[email protected]/client?bundle",
6 "td-playground/src/App.tsx": "blob:null/8a3f1b2c-..."
7 }
8}
9</script>

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.

TS
1const PLAYGROUND_LOCAL_PREFIX = "td-playground";
2
3function toVirtualSpecifier(path: string): string {
4 return `${PLAYGROUND_LOCAL_PREFIX}${normalizePath(path)}`;
5}
6// "/src/App.tsx" → "td-playground/src/App.tsx"

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.

Setting up the editor...

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:

JS
1import { x } from "specifier";
2import("specifier");
3export { y } from "specifier";

But they also do not appear in contexts that look similar:

JS
1const s = "import from 'fake'"; // string literal
2// import from "commented out" // comment
3const t = `import from "${dynamic}"`; // template literal

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:

4 annotations
TS
1// ❌ Regex approach — brittle
2const importRegex = /import .+ from ['"](.+)['"]/g;
3// Breaks on: multiline imports, comments containing
4// import-like text, dynamic import(), template literals
5
6// ✅ Lexer approach — robust
7import { parse } from "es-module-lexer";
8const [imports] = parse(code);
9// Handles all edge cases: re-exports, dynamic imports,
10// string escaping, comments — because it actually parses

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.

TS
1import { init, parse } from "es-module-lexer";
2
3await init; // one-time WASM initialization
4
5const [imports] = parse(code);
6// imports = [
7// { n: "react", s: 27, e: 32 }, // s = start of specifier, e = end
8// { n: "./App", s: 68, e: 73 },
9// ]

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:

TS
1import MagicString from "magic-string";
2
3const magicString = new MagicString(code);
4
5for (const item of imports) {
6 if (!item.n) continue;
7 const newSpecifier = rewriteSpecifier(item.n);
8 // s and e are offsets into the original string — no adjustment needed
9 magicString.overwrite(item.s, item.e, `"${newSpecifier}"`);
10}
11
12const rewritten = magicString.toString();

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:

TS
1const blobUrl = URL.createObjectURL(
2 new Blob([rewrittenCode], { type: "text/javascript" })
3);

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:

TS
1importMap["td-playground/src/App.tsx"] = blobUrl;
2importMap["td-playground/src/utils.ts"] = blobUrl2;
3importMap["react"] = "https://esm.sh/[email protected]?bundle";

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:

TS
1export type PlaygroundBuildResult = {
2 srcDoc: string;
3 blobUrls: string[];
4};
TS
1// In the component, before triggering a new build:
2revokeBlobUrls(blobUrlsRef.current);
3blobUrlsRef.current = [];
4
5// After the build succeeds:
6blobUrlsRef.current = result.blobUrls;

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:

HTML
1<!doctype html>
2<html>
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <script type="importmap">
7 {
8 "imports": {
9 "react": "https://esm.sh/[email protected]?bundle",
10 "react/jsx-runtime": "https://esm.sh/[email protected]/jsx-runtime?bundle",
11 "react-dom/client": "https://esm.sh/[email protected]/client?bundle",
12 "td-playground/src/main.tsx": "blob:null/...",
13 "td-playground/src/App.tsx": "blob:null/...",
14 "td-playground/src/utils.ts": "blob:null/..."
15 }
16 }
17 </script>
18 </head>
19 <body>
20 <div id="root"></div>
21 <script>/* error handler */</script>
22 <script type="module">
23 import("td-playground/src/main.tsx");
24 </script>
25 </body>
26</html>

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:

TS
1function escapeScriptEnd(value: string): string {
2 return value.replace(/<\/script/gi, "<\\/script");
3}

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.

Setting up the editor...

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:

4 annotations
TS
1// Error boundary for the preview
2iframe.contentWindow.onerror = (message, source, line) => {
3 setError({
4 message: String(message),
5 line: line ?? 0,
6 source: source ?? "unknown",
7 });
8 return true; // prevent default console error
9};
10
11// Runtime error catching
12try {
13 const result = Babel.transform(code, config);
14 setTranspiled(result.code);
15 setError(null);
16} catch (err) {
17 setError({
18 message: err.message,
19 line: err.loc?.line ?? 0,
20 source: "babel",
21 });
22}

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:

TS
1const uri = monaco.Uri.parse(`file://${file.path}`);
2let model = monaco.editor.getModel(uri);
3if (!model) {
4 model = monaco.editor.createModel(file.content, file.language, uri);
5}

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:

2 annotations
TS
1// Coerce common CSS patterns for iframe context
2function coerceCSS(raw: string): string {
3 return raw
4 .replace(/\.module\.css/g, ".css")
5 .replace(/:root/g, ":scope")
6 .replace(/@import\s+['"][^'"]+['"];?/g, "");
7}

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.

Setting up the editor...

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.