npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mini-react-repl

v0.23.1

Published

Browser-only React TSX REPL — multi-file editor, live preview, real Fast Refresh, no backend.

Readme

mini-react-repl

npm types license CI demo

A multi-file React + TSX REPL that runs entirely in the browser. Edit, see the result live, ship the whole thing as static files

preview

npm i mini-react-repl monaco-editor react react-dom
import { useState } from 'react';
import { Repl } from 'mini-react-repl';
import { defaultVendor } from 'mini-react-repl/vendor-default';
import { MonacoReplEditor } from 'mini-react-repl/editor-monaco';
import 'mini-react-repl/theme.css';

export default function Playground() {
  const [files, setFiles] = useState({
    'App.tsx': 'export default () => <h1>hi</h1>',
  });
  return (
    <Repl editor={MonacoReplEditor} vendor={defaultVendor} files={files} onFilesChange={setFiles} />
  );
}

That's the whole thing. Editor + sandboxed live preview, multi-file, real React Fast Refresh, no backend, no SSR, no server-side bundling.


What you get

  • multi-file TSX/TS/CSS, with imports across files
  • bare specifier imports (import dayjs from 'dayjs') for a curated vendor set, swappable for your own
  • React Fast Refresh — component state survives edits
  • inline source maps so stack traces point at your .tsx, not transpiled JS
  • Monaco gets pre-configured (automatic JSX, ES2022, bundler resolution) and pre-baked .d.ts for the curated vendor set — real squiggles + hover for react, react-dom, dayjs out of the box
  • strictly controlled state. you own the file table. persistence, sharing, undo, multi-tab sync — all yours to wire up however

What it doesn't do, and won't pretend to

  • arbitrary npm at runtime, by default. the vendor set is fixed at build time and there's a builder if you want a different set. an opt-in cdn prop lazy-loads anything outside that set from esm.sh on demand — off unless you pass it, so the default stays "no surprise network calls". see Lazy npm via esm.sh.
  • type errors as a build gate. swc strips types, the iframe runs. Monaco shows red squiggles (user files + vendor packages, via the pre-baked .d.ts), but a type error never blocks the run. like Vite dev: diagnostics are advisory.
  • folders. flat file list. ./Counter, not ./components/Counter.
  • CJS. ESM-only. modern browsers only — Chrome 109+, FF 108+, Safari 16.4+.
  • persistence, sharing, templates, console capture. open DevTools for the console. write to IDB if you want to persist. the API gives you files and onFilesChange, the rest is on you. this is a feature.

If those are dealbreakers look at Sandpack (unmaintained) or StackBlitz WebContainers (monthly subscription) instead — they make different trade-offs and they're great at them


API

import {
  Repl, // convenience: tabs + editor + preview
  ReplProvider, // headless: just the engine + context
  ReplFileTabs, // headless: tabs UI
  ReplPreview, // headless: the iframe + error overlay
  ReplErrorOverlay, // standalone overlay component
  useRepl, // hook: files + crud actions
} from 'mini-react-repl';

<Repl/> props

| prop | type | required | default | | | ------------------------- | ------------------------------------------------- | -------- | ----------------------------- | ----------------------------------------------------------------------------------- | | files | Record<string, string> | yes | — | flat path → source map | | onFilesChange | (next) => void | yes | — | called on every set/remove/rename | | vendor | VendorBundle \| Promise<{ default }> | yes | — | { importMap, types? }; promise/thunk forms code-split it | | editor | React.FC<ReplEditorProps> | yes | — | adapter component | | entry | string | no | 'App.tsx' | the logical entry path | | transformDebounceMs | number | no | 150 | | | sandbox | string | no | 'allow-scripts allow-forms' | iframe sandbox tokens; pass extras to extend | | unsafeDropSandbox | true | no | — | drop the sandbox attribute entirely (only for fully trusted code) | | virtualModules | Record<string, string> | no | — | inline modules user code can import; see Virtual modules | | baseHref | string \| null | no | window.location.origin | <base href> for root-relative URLs in user code; null omits it | | headHtml | string | no | '' | injected into iframe <head> | | bodyHtml | string | no | '' | injected into iframe <body> | | showPreviewErrorOverlay | boolean | no | true | toggle built-in overlay | | onPreviewError | (err: ReplError) => void | no | — | transform + runtime errors | | onMounted | () => void | no | — | fires when the iframe runtime mounts the entry module | | iframeRef | Ref<HTMLIFrameElement> | no | — | forwarded to the underlying <iframe>; postMessage host data in | | onAddFile | () => MaybePromise<string \| null \| undefined> | no | — | custom add-file dialog; return the new path, or nullish to cancel | | onDeleteFile | (path) => MaybePromise<boolean \| void> | no | — | confirm/cancel deletion; return false to cancel | | swcWasmUrl | string | no | jsdelivr CDN | self-host this for offline / CI | | loader | ReplLoader | no | — | per-file pre-processor; see Custom file types | | cdn | ReplCdnResolver | no | — | lazy-load bare specifiers off a CDN; see Lazy npm via esm.sh |

useRepl()

const { files, setFile, removeFile, renameFile, activePath, setActivePath, reloadPreview } =
  useRepl();

That's it. No errors, no forceRefresh, no getPreviewIframe. By design — errors come through onPreviewError. reloadPreview() is the recovery hatch when user code crashes past what Fast Refresh can handle (empty entry file, top-level throw): it remounts the iframe and re-runs every transform.

Headless layout

<ReplProvider files={files} onFilesChange={setFiles} vendor={defaultVendor}>
  <Sidebar>
    <ReplFileTabs />
  </Sidebar>
  <Main>
    <Top>
      <MyEditor />
    </Top>
    <Bottom>
      <ReplPreview
        headHtml={`<script src="https://cdn.tailwindcss.com"></script>`}
        onPreviewError={(e) => toast(e.message)}
      />
    </Bottom>
  </Main>
</ReplProvider>

ReplProvider is just context + the engine. lay it out however.


Vendor

The vendor bundle is what lets import dayjs from 'dayjs' work. it's a curated, prebuilt set of ESM modules + an import map.

Default

import { defaultVendor } from 'mini-react-repl/vendor-default'

<Repl vendor={defaultVendor} ... />

includes: react@19, react-dom@19, react/jsx-runtime, react/jsx-dev-runtime, dayjs@1. inlined as base64 data URLs so it works under srcdoc with zero hosting setup. ~200 kB gzipped JS (most of it the development build of react-dom, which Fast Refresh needs), plus pre-baked .d.ts (vendor.types) so Monaco shows real red squiggles + hover signatures for the same packages.

date-fns and lodash-es were dropped from the default set in 0.23.0 — modern browsers cover most of what they did, and the cdn prop lazy-loads them (or anything else) from esm.sh when you actually need them. see Lazy npm via esm.sh.

the import map and the .d.ts payload are both code-split. the import-map chunk loads when <Repl/> mounts; the types chunk loads when an editor mounts. preview-only or non-Monaco consumers never download types; routes that never mount <Repl/> ship neither. need to warm a chunk earlier (prefetch on hover, idle, etc.)? call defaultVendor.importMap() or defaultVendor.types() directly — both return the same promise the library awaits on mount, so the chunks land in cache before they're needed.

if your demo needs literally only those libs, stop reading.

Custom

You're going to outgrow the default. when that happens, write a vendor.entry.ts that declares the bundle shape via standard ESM imports/exports:

// src/sandbox/vendor.entry.ts
// Re-exports the iframe-runtime required core (react, react-dom,
// react-dom/client, react/jsx-runtime, react/jsx-dev-runtime,
// react-refresh/runtime). Skip this and the build errors loudly.
export * from 'mini-react-repl/vendor-base';

import * as zod from 'zod';
import * as framer from 'framer-motion';
import * as lodash from 'lodash-es'; // alias source: iframe imports 'lodash'

export { zod, framer as 'framer-motion', lodash };

then build it:

# repl-vendor-build needs esbuild; it's an optional peer dep, install once:
npm i -D esbuild

npx repl-vendor-build src/sandbox/vendor.entry.ts
# → src/sandbox/vendor.generated/index.ts          (exports customVendor)
# → src/sandbox/vendor.generated/import-map.json   ({ imports: { ... } })
# → src/sandbox/vendor.generated/types.json        (.d.ts payload)

The output folder name (<basename-without-.entry.ext>.generated) is matched by most default ignore globs (Prettier, oxlint, Knip) and is meant to be gitignored as a unit. Pass --out <dir> to override.

Drop it into <Repl>:

import { customVendor } from './sandbox/vendor.generated';

<Repl vendor={customVendor} ... />

Both the import-map and types are code-split. Routes that never mount <Repl/> ship neither; preview-only consumers never download types. SSR-safe. The generated index.ts has a loading-sequence diagram in its header comment if you want the details.

The CLI is the only supported entry point — there is no public Node API. Wire it into your build via an npm script ("build:vendor": "repl-vendor-build src/vendor.entry.ts") or a pre-build hook.

Mix

To put back libraries that aren't in the default set (e.g. date-fns and lodash-es, dropped in 0.23.0) alongside your own additions, declare them all in one vendor.entry.ts:

// src/sandbox/vendor.entry.ts
export * from 'mini-react-repl/vendor-base';

import * as dateFns from 'date-fns';
import * as dayjs from 'dayjs';
import * as lodash from 'lodash-es';
import * as zod from 'zod'; // your additions

export { dateFns as 'date-fns', dayjs, lodash as 'lodash-es', zod };

One import map, one types chunk, no runtime composition. Runtime composition isn't supported anyway — defaultVendor.importMap / customVendor.importMap are lazy thunks now, not plain objects.

Virtual modules

For ad-hoc helpers you don't want to ship as a vendor chunk — small utilities, theming primitives, mock APIs — pass them inline:

const VIRTUAL_MODULES = {
  '@app/util': `export const greet = (name: string) => 'hello ' + name`,
} as const;

<Repl files={files} virtualModules={VIRTUAL_MODULES} ... />

User code in the REPL can import { greet } from '@app/util' — the iframe runtime executes it; Monaco autocompletes against the source. No bundling, no hosting, no import-map entry. Virtuals can import each other and any vendor package (react, dayjs, …) — the iframe's existing dep substitution and the import map handle both.

Boot-time only. Snapshotted on first mount, identical to vendor. Hoist to a top-level as const so the reference stays stable. Collisions with vendor.importMap.imports keys resolve in favor of the virtual. CSS aliases are not yet supported.

See examples/virtual-modules/ for a working setup with cross-virtual imports.

Lazy npm via esm.sh

The vendor set is curated and fixed at build time. To reach for a package that isn't in it without rebuilding, pass the opt-in cdn prop — bare specifiers the vendor import map doesn't cover are lazy-loaded from a CDN on demand, at the moment user code imports them:

import { createEsmShCdnHandler } from 'mini-react-repl/cdn-esmsh';

// Build it ONCE at module scope — a stable reference. Pin versions for
// anything you ship publicly so the lazy import is reproducible.
const cdn = createEsmShCdnHandler({ versions: { 'canvas-confetti': '1.9.3' } });

<Repl files={files} vendor={defaultVendor} cdn={cdn} ... />
// …then user code can just import it:
import confetti from 'canvas-confetti'; // resolved from esm.sh on demand

It's layered behind the vendor map, never instead of it: vendor specifiers always win, so the React singleton, offline support, and Monaco types stay intact for the curated set. esm.sh resolves the long tail. Each emitted URL carries ?external=<vendor keys> so a lazy package reuses the vendor's React (and every other vendor singleton) instead of bundling its own — without that, two Reacts means "Invalid hook call".

createEsmShCdnHandler takes versions (pin for reproducibility), allow (an allowlist gate), origin (a self-hosted mirror), and query (extra esm.sh flags, e.g. { bundle: true } to flatten a deep dep tree into one request). cdn is any (specifier, sharedDependencies, fromPath) => string | null though, so jsDelivr +esm or your own resolver work too. Lazy modules have no .d.ts, so they show as Monaco squiggles even though they run fine.

Caveats: it makes user code call a third-party CDN — leaving cdn unset keeps the static-deploy, no-surprise-calls default. If you set a CSP, allow script-src https://esm.sh (module fetches, static and dynamic, are governed by script-src). Boot-time only, like vendor.

See examples/cdn-esmsh/ for a working setup (and the React-singleton proof).


Editor

You bring your own. the library doesn't bundle one by default. there's an adapter contract:

type ReplEditorProps = {
  path: string;
  value: string;
  onChange: (next: string) => void;
  language: 'typescript' | 'javascript' | 'css';
  types?: TypeBundle; // forwarded from vendor.types; ignore if you don't care
};

write a component matching this shape, pass it as editor={...}. that's the whole interface.

The Monaco one

Most people are going to want Monaco. shipped under a separate import path so its weight is opt-in:

import { MonacoReplEditor } from 'mini-react-repl/editor-monaco';

monaco-editor is an optional peer dep — only the people who import this path install it. you'll still need a Monaco worker setup for your bundler (vite-plugin-monaco-editor or monaco-editor-webpack-plugin), which Monaco needs whether or not you're using this library.

type-checking config

MonacoReplEditor configures Monaco's TS service on mount with compiler options matching the runtime transform: automatic JSX, ES2022, bundler resolution, strict, etc. Without this Monaco's defaults reject every .tsx file with TS17004 (--jsx not provided) and every bare specifier with TS2792 (module not found). If you want to override:

<MonacoReplEditor compilerOptions={{ strict: false }} />

Same for diagnosticsOptions. Both are passthroughs to monaco.languages.typescript.typescriptDefaults.

If vendor.types is set (the default vendor pre-bakes it; the builder emits types.json alongside import-map.json in the generated folder), MonacoReplEditor registers each .d.ts via addExtraLib so users get real diagnostics + hover signatures for vendor packages. vendor.types also accepts a Promise<TypeBundle> or a thunk so the types chunk loads in parallel to the rest of the app — and can be skipped entirely on routes that never mount an editor.

Monaco's theme defaults to 'auto', which wires up a tiny watcher that follows the host page's prefers-color-scheme. Pass any registered theme name ('vs-dark', your own, …) to pin it and skip the watcher.

CodeMirror? plain textarea?

Sure. write the adapter:

const TextAreaEditor: React.FC<ReplEditorProps> = ({ value, onChange }) => (
  <textarea value={value} onChange={(e) => onChange(e.target.value)} />
);

doesn't get more "bring your own" than that.


Custom file types

Every file flows through a loader. The default handles .css (injected as <style>) and .tsx / .ts / .jsx / .js (swc-compiled). Anything else is ignored.

Pass loader to claim more extensions. The function runs once per file (on first load and on every change) and gets the file's source plus a transform function — the same swc-wasm pipeline the default loader uses — so you can keep delegating TS/JSX compilation. Return a ReplLoaderResult to claim the file, or null / undefined to fall through:

import { defaultLoader, type ReplLoader } from 'mini-react-repl/loader';

const loader: ReplLoader = async (input) => {
  if (input.path.endsWith('.sqlite')) {
    // emit plain JS — no swc needed
    return {
      kind: 'module',
      code: `export default ${JSON.stringify(parseSqlite(input.source))};`,
    };
  }
  if (input.path.endsWith('.md')) {
    // generate TSX, then run it through the same swc pass `.tsx` files use
    const tsxSource = mdxToTsx(input.source);
    return { kind: 'module', code: await input.transform(tsxSource, { tsx: true }) };
  }
  return defaultLoader(input);
};

<Repl
  files={files}
  onFilesChange={setFiles}
  vendor={defaultVendor}
  editor={MonacoReplEditor}
  loader={loader}
/>;

ReplLoaderResult is a discriminated union:

| variant | meaning | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | { kind: 'css', source: string } | inject source as a <style> tag | | { kind: 'module', code: string } | code is already-compiled JS; the engine runs rewriteImports on it so relative specifiers resolve to other files | | null / undefined | skip the file |

A user file can import data from './data.sqlite' once the loader claims it — relative imports resolve against the files map by literal name first, so non-standard extensions just work as long as you write them out.

The loader prop is boot-time only (like vendor / entry); to swap, remount the provider with a different key.


Styling

unstyled by default. components emit stable class names + data attributes:

<div class="repl-tabs">
  <button class="repl-tab" data-active="true">App.tsx</button>
</div>
<div class="repl-preview"><iframe class="repl-iframe"></iframe></div>
<div class="repl-error-overlay">…</div>

three options:

import 'mini-react-repl/theme.css'; // sane defaults, light + dark

…or write your own CSS targeting .repl-* and [data-active].

…or wrap each component, every one accepts className and style.

no Tailwind dep, no CSS-in-JS, no global pollution beyond the class names.


Iframe extras

The preview is a srcdoc. you can inject into it:

<ReplPreview
  headHtml={`
    <script src="https://cdn.tailwindcss.com"></script>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
    <script>window.MY_API_KEY = 'demo-key'</script>
  `}
  bodyHtml={`<div id="my-extra-portal"></div>`}
/>

headHtml runs before the import map and runtime, so don't try to use the registry from there. bodyHtml is appended after #root, run anything that should run after the React root mounts.

The preview document is loaded from a sandboxed blob: URL, so root-relative URLs in user code (e.g. <img src="/img/yak-jumping.png">) would resolve against the opaque blob: origin — which has no server behind it — and fail. A <base href> is emitted as the first element in <head> to re-point those URLs at a real origin. It defaults to the embedder's window.location.origin; override it for a different asset host, or pass baseHref={null} to drop the tag entirely.

<ReplPreview baseHref="https://cdn.example.com/" />

if you want Tailwind in your previews this is where it goes. the library doesn't bundle it.


Element inspection

Optional subpath that ships an in-iframe element picker. Click an element in the live preview, get back the JSX call site that produced it with file, line, column, component name making use of the source-maps automatically

import { Repl } from 'mini-react-repl';
import { InspectMode } from 'mini-react-repl/inspect';

const [picking, setPicking] = useState(false);

<>
  <button onClick={() => setPicking(true)}>Inspect</button>
  <Repl files={files} onFilesChange={setFiles} vendor={vendor} editor={MonacoReplEditor}>
    <InspectMode
      active={picking}
      onElementPicked={(pick) => {
        setPicking(false);
        // pick.dom.tag    → 'h1'
        // pick.dom.text   → 'Today is Monday'
        // pick.stack[0]   → { fileName: 'App.tsx', lineNumber: 7, columnNumber: 7, componentName: 'App' }
      }}
      onCancel={() => setPicking(false)}
    />
  </Repl>
</>;

<InspectMode/> must live inside the surrounding <ReplProvider/> (or <Repl>, which renders one) — it discovers the live iframe through provider context, no ref plumbing required.

The picker is lazy-injected into the iframe on first activation — consumers who never import mini-react-repl/inspect don't pay any byte for the inspection feature

Pick shape

type ElementPick = {
  dom: { tag: string; text: string | null; boundingRect: DOMRectReadOnly };
  stack: StackFrame[];
};

type StackFrame = {
  fileName: string;
  lineNumber: number;
  columnNumber: number;
  componentName: string | null;
};

The stack is ordered top-down: index 0 is the JSX call closest to the clicked DOM node; later frames walk up the React _debugOwner chain. Every position is in source space, ready to hand straight to an "open in editor" link


Errors

<Repl
  showPreviewErrorOverlay={true} // default
  onPreviewError={(err) => {
    if (err.kind === 'runtime') sentry.captureException(err);
    setLastError(err);
  }}
/>
type ReplError =
  | { kind: 'transform'; path: string; message: string; loc?: { line: number; column: number } }
  | { kind: 'runtime'; message: string; stack: string }
  | { kind: 'resolve'; path: string; specifier: string };

both transform and runtime errors flow through the same callback. line/col is mapped to original .tsx via inline source maps.

when transform fails, the previous render stays mounted. you don't lose your DOM because you forgot a }. this is intentional and the thing that makes it feel like Vite dev rather than a "syntax error → blank page" REPL.

set showPreviewErrorOverlay={false} if you want to render the error yourself.


How it actually works

The interesting part, and the part that took the longest to get right.

  1. you change a file. <ReplProvider/> debounces 150ms.
  2. the changed file is shipped to a Web Worker running swc-wasm. swc strips types, transforms JSX (automatic runtime), and injects React Refresh signatures.
  3. main thread takes the JS back, runs an import-rewrite pass:
    • bare specifiers ('react', 'dayjs') are left alone — the iframe has a native <script type="importmap"> that resolves them
    • relative specifiers ('./Counter') get rewritten to the current blob URL of that logical path
  4. wrapped code becomes a Blob, becomes a URL.createObjectURL, gets postMessaged to the iframe.
  5. the iframe imports the blob URL. on top-level execution, the module commit()s itself into a global registry keyed by logical path, not blob URL. React Refresh sees stable IDs, patches components in place, state survives.

The iframe itself is a srcdoc — generated once, never recomputed on file edits. blobs come and go through postMessage. errors come back the same way.

That's the whole pipeline. ~30KB of glue around swc-wasm and react-refresh.


Caveats

things that will bite you. read this part.

  • bundler-native worker imports. the library does new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }). works in Vite, Rollup 4+, Webpack 5+, esbuild bundle, Parcel 2. doesn't work in pure-CDN no-bundler setups. there's a /standalone entry planned for v2 with the inline-Blob-worker trick.
  • srcdoc origin. stack traces show about:srcdoc:42 for the iframe HTML itself. blob: URLs for transformed user code. inline source maps map back to original .tsx. DevTools "Sources" works. but if you grep frames for a pretty path you'll see srcdoc.
  • swc-wasm fetches at runtime. by default from jsdelivr. self-host it for offline / CI / strict CSP:
    <Repl swcWasmUrl="/swc.wasm" />
  • cdn (esm.sh) is off by default. the engine itself makes no third-party calls; only a cdn-resolved lazy import reaches out. with a CSP, allow script-src https://esm.sh. see Lazy npm via esm.sh.
  • Monaco workers are not our problem. if Monaco is your editor, you have to configure its workers in your bundler. there's no way around this and every Monaco-based library has the same constraint.
  • CSS files. alphabetical concat across files. one <style> per file. @import doesn't resolve (browsers will try and fail).
  • rename/delete breaks importers. no auto-fix. importing files will fail to transform, the overlay shows Module not found, last-good render stays. fix the import yourself. (consider this a feature: predictable, no magic.)
  • default sandbox is allow-scripts allow-forms. the iframe runs with a unique opaque origin, so user code can't reach window.parent, localStorage, or cookies of your app. unsafeDropSandbox drops the attribute entirely — only do that for fully trusted code. for hostile inputs, additionally serve the host page from a separate origin so the sandbox can't be undone by a host-side XSS.
  • strictly controlled state means re-renders are yours to manage. if you do onFilesChange={files => setHeavyState(files)} and setHeavyState is expensive, that's on you. the library debounces transformation (150ms) but not your setState.

Compared to

| | this | Sandpack | StackBlitz WebContainers | | ------------------ | ------------------------------------------------------- | --------------------------------- | --------------------------------------------------- | | how it transforms | swc-wasm in a worker, browser only | bundler in a worker, browser only | full Node in WASM, real npm | | arbitrary npm | curated set; opt-in esm.sh for the long tail (cdn) | yes, via esm.sh | yes, real npm install | | static deploy | yes, no backend at all | yes | no, needs CSP/COOP/COEP headers from special origin | | backend, ssr, etc. | no | no | yes, runs Node | | bundle size | small (engine ~30KB + swc 3MB wasm + your vendor) | medium | enormous, but you get a whole VM | | works offline | yes (with self-hosted swc.wasm + inline default vendor) | partly | no |

if you need real npm, real Node, you want WebContainers. if you need arbitrary client-side npm with a CDN runtime, Sandpack. if you want a fast, boring, static-deployable React playground with a known set of libs, this.


FAQ

how do I add a library? write a vendor.ts (re-export mini-react-repl/vendor-base plus your own deps), run npx repl-vendor-build vendor.ts, pass the result. see Vendor.

can I use this for tutorials / blog post embeds? yes — that's the main use case. srcdoc preview means it works inside an iframe-in-an-iframe just fine. ship the demo as a static page, embed anywhere.

why not just use Vite? Vite needs a dev server. this doesn't. drop the build output on a static host and you're done.

why no Service Worker for module resolution? considered it. SW gives stable URLs (good for Refresh) but adds lifecycle complexity, scope rules, registration timing. blob URLs + a logical-path registry get the same Refresh behavior with no SW required. trade-off favored simplicity.

HMR for non-component edits? React Refresh handles it. utility/hook edits invalidate up to the nearest component boundary, which gets re-rendered. cascade is usually 1–2 modules deep. you don't have to think about it.

can I get TypeScript red squiggles for the vendor libs? yes — the default vendor pre-bakes .d.ts for react, react-dom, dayjs, and MonacoReplEditor registers them with Monaco's TS service via addExtraLib. for custom vendors, repl-vendor-build emits a types.json next to import-map.json in the generated folder. swc still strips types at runtime — diagnostics are editor-side only and don't gate the transform.

does it work in Storybook / Docusaurus / Notion-like embeds? yes — srcdoc preview means it doesn't care what frame it's rendered in.

why ESM-only? no CJS? it's 2026. our minimum browser is Safari 16.4. node 20 understands ESM. CJS is a tax we don't want to pay.


Dev

git clone https://github.com/jantimon/mini-react-repl
cd mini-react-repl
pnpm install
pnpm dev          # runs examples/e2e-fixture (the Playwright target)
pnpm test         # vitest
pnpm test:e2e     # playwright (chromium only for now)
pnpm build        # tsup, library only

E2E tests run against examples/e2e-fixture on chromium only for v1. firefox + webkit are deferred until the chromium suite is stable. see SPEC.md §17.

PRs welcome. small ones land fast. for anything architectural, open an issue first — there's a decision log in SPEC.md §20 covering the tradeoffs already made, please skim before proposing reverts.


License

MIT