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.12.0

Published

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

Downloads

2,257

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 { MonacoReplEditor } from 'mini-react-repl/editor-monaco';
import 'mini-react-repl/theme.css';

const HELLO = {
  'App.tsx': 'export default () => <h1>hi</h1>',
};

export default function Playground() {
  const [files, setFiles] = useState(HELLO);
  return (
    <Repl
      editor={MonacoReplEditor}
      files={files}
      onFilesChange={setFiles}
      vendor={import('mini-react-repl/vendor-default')}
    />
  );
}

That's the whole thing. Editor + 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 { format } from 'date-fns') 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, date-fns, dayjs, lodash-es 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. the vendor set is fixed at build time. there's a builder if you want a different set. no esm.sh fallback in v1.
  • 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


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', 'date-fns') 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.


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 | yes | — | { importMap, baseUrl? } | | editor | React.FC<ReplEditorProps> | yes | — | adapter component | | entry | string | no | 'App.tsx' | the logical entry path | | transformDebounceMs | number | no | 150 | | | 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 |

Custom file types

Every file flows through a loader. The default is defaultLoader, which implements the historic dispatch: .css<style> injection, .tsx / .ts / .jsx / .js → swc-compiled module, anything else → ignored.

Pass loader to replace it. The function runs once per file (on first load and on every change). It receives the file's source plus a transform function that's the same swc-wasm pipeline defaultLoader uses — call it from inside your loader if you need TS/JSX compilation. Return a ReplLoaderResult to claim the file, or null / undefined to skip it. Most loaders delegate unhandled extensions back to defaultLoader:

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

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.

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 { format } from 'date-fns' 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, date-fns@3, dayjs@1, lodash-es@4. inlined as base64 data URLs so it works under srcdoc with zero hosting setup. ~400KB gzipped JS, plus pre-baked .d.ts (vendor.types) so Monaco shows real red squiggles + hover signatures for the same packages — also opt-in via this subpath import.

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

Custom

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

// vendor.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 vendor.ts \
  --out public/vendor \
  --bundle-out src/vendor/repl.vendor.json
# → public/vendor/<chunks>.js     (one ESM chunk per package, served at /vendor/*)
# → public/vendor/repl.types.json (.d.ts payload, fetched at runtime)
# → src/vendor/repl.vendor.json   (just the import map — bundler-imported)

types live next to the JS chunks rather than inlined in the bundler-imported JSON, so the bundler chunk stays tiny (a few KB) and the multi-MB .d.ts payload is fetched in parallel. The bundle JSON embeds a typesUrl pointer, so <Repl/> does the fetch itself — wiring is just:

import vendor from './vendor/repl.vendor.json';
<Repl vendor={vendor} ... />

or code-split:

<Repl vendor={import('./vendor/repl.vendor.json')} ... />

the builder is an esbuild wrapper. format: 'inline' emits base64 data URLs (stay-within-srcdoc, no hosting). format: 'hosted' emits real files with content hashes you can serve under Cache-Control: immutable

programmatic API too if you want to run it from a script:

import { build } from 'mini-react-repl/vendor-builder';
const vendor = await build({
  entry: 'vendor.ts',
  format: 'hosted',
  outDir: 'public/vendor',
  types: 'embed', // optional; default 'omit'
});

Mix

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

const vendor = {
  importMap: {
    imports: {
      ...defaultVendor.importMap.imports,
      zod: '/vendor/zod.js',
    },
  },
  baseUrl: '/vendor',
};

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, date-fns, …) — 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.


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 repl.types.json next to the chunks for custom vendors), MonacoReplEditor registers each .d.ts via addExtraLib so users get real diagnostics + hover signatures for vendor packages. vendor.types also accepts a Promise<TypeBundle> so a runtime fetch('/vendor/repl.types.json') loads in parallel to the rest of the app.

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.


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.

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.


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" />
  • 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.)
  • no sandbox attribute on the iframe. user code shares origin with your app. if you're hosting third-party snippets, you want a separate origin — v1 doesn't ship that. trust model is: people editing their own code on their own machine.
  • 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 | no, curated | 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, date-fns, dayjs, lodash-es, and MonacoReplEditor registers them with Monaco's TS service via addExtraLib. for custom vendors, repl-vendor-build emits a repl.types.json next to the JS chunks. 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