Home → Bundle a Preact (or React) app into one standalone script with esbuild or Rollup

Bundle a Preact (or React) app into one standalone script with esbuild or Rollup

By · Node.js & JavaScript developer
Published October 2, 2022 · Updated October 1, 2026

Sometimes you don't want an app, you want a widget: one JavaScript file that someone drops into a page with a <script> tag, and that renders a component into a <div>. Think of an embeddable form, a pricing calculator, or a status badge. The host page might be WordPress, plain HTML, or a different framework, so the bundle has to carry everything with it.

Preact is a good fit: the same component model as React at a fraction of the size. This post builds a small widget with esbuild (one dependency, one command) and with Rollup 4 (no Babel needed any more), and measures the result against React. Everything was run on a stock node:24-slim image with [email protected], [email protected] and [email protected], and each bundle was loaded into a jsdom page to check that it mounts, updates and unmounts.

Preact 11 changed the rules a bit

Preact 11 was released on 30 September 2026. Some of its changes matter for a standalone build:

  • ESM only. The CommonJS and UMD builds from Preact 10 are gone, so you can't point a <script> tag at preact.umd.js from a CDN any more. A bundler that turns ESM into one script is now the normal way to do this.
  • Refs are forwarded by default. Function components no longer need forwardRef.
  • defaultProps and automatic px suffixing for numeric styles moved from core into preact/compat. Use default parameters instead, as below.
  • The minimum supported browsers are now Chrome 71, Safari 12.1, Firefox 69 and Edge 79.

The widget

A ticking clock with hooks is enough to prove that rendering, state updates, effects and cleanup all work. The important part is the mount export: the host page calls it with an element and props, and gets back a function that unmounts the widget and clears its timer.

npm install preact
npm install --save-dev esbuild

src/widget.jsx:

import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';

function Clock({ label = 'Local time' }) {
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  return <p class="clock">{label}: {now.toLocaleTimeString('en-GB')}</p>;
}

// The page calls ClockWidget.mount(element, props) and gets back an unmount function.
export function mount(el, props) {
  render(<Clock {...props} />, el);
  return () => render(null, el);
}

Option 1: esbuild, one command

npx esbuild src/widget.jsx --bundle --minify --format=iife --global-name=ClockWidget --jsx=automatic --jsx-import-source=preact --outfile=dist/clock.js

  dist/clock.js  13.7kb

⚡ Done in 13ms

What each flag does:

  • --bundle inlines Preact and everything else the widget imports.
  • --format=iife wraps the result in a function that runs as soon as the script loads. That's what a classic <script> tag expects.
  • --global-name=ClockWidget puts the module's exports on window.ClockWidget, so the page can call ClockWidget.mount(…).
  • --jsx=automatic --jsx-import-source=preact compiles JSX to calls into preact/jsx-runtime. You don't need to import h in every file, and there's no Babel.

The host page only needs this:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Clock widget</title>
  </head>
  <body>
    <div id="clock"></div>
    <script src="/dist/clock.js"></script>
    <script>
      ClockWidget.mount(document.getElementById('clock'), { label: 'Berlin' });
    </script>
  </body>
</html>

For development, esbuild has a built-in server. With --servedir it serves your HTML and builds the bundle in memory, and with --watch it rebuilds when a source file changes. That replaces the old rollup-plugin-serve + rollup-plugin-livereload setup. (Live reload isn't automatic from the CLI, so refresh the page.) As package.json scripts:

"scripts": {
  "build": "esbuild src/widget.jsx --bundle --minify --format=iife --global-name=ClockWidget --jsx=automatic --jsx-import-source=preact --outfile=dist/clock.js",
  "dev": "esbuild src/widget.jsx --bundle --format=iife --global-name=ClockWidget --jsx=automatic --jsx-import-source=preact --outdir=public/dist --entry-names=clock --sourcemap --watch --servedir=public"
}

npm run dev serves public/ on http://127.0.0.1:8000/, and /dist/clock.js plus its source map are built on the fly.

Option 2: Rollup 4, without Babel

The usual Rollup setup for JSX used to be Babel, @babel/preset-react, @rollup/plugin-babel and a handful of other plugins. Rollup 4 parses JSX itself, so you need just two plugins: one to resolve node_modules and one to minify.

npm install --save-dev rollup @rollup/plugin-node-resolve @rollup/plugin-terser

rollup.config.mjs:

import { nodeResolve } from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';

export default {
  input: 'src/widget.jsx',
  // Rollup 4 parses JSX itself, so there's no Babel. Unlike esbuild and
  // TypeScript, jsxImportSource is the full runtime module path, not the
  // package name. importSource + factory cover the rare classic-mode fallback
  // (a `key` after a spread).
  jsx: {
    mode: 'automatic',
    jsxImportSource: 'preact/jsx-runtime',
    importSource: 'preact',
    factory: 'h',
  },
  output: {
    file: 'dist/clock.rollup.js',
    format: 'iife',
    name: 'ClockWidget',
  },
  plugins: [nodeResolve({ extensions: ['.js', '.jsx'] }), terser()],
};

Watch out for jsxImportSource. In esbuild and TypeScript, the JSX import source is a package name (preact). In Rollup it's the full runtime module path (preact/jsx-runtime). Both of these mistakes are easy to make, and they fail differently:

  • { preset: 'react-jsx', importSource: 'preact' } builds without an error, but the preset still imports react/jsx-runtime. Rollup only warns that it's an unresolved dependency and guesses a global for it, so the bundle crashes in the browser with ReferenceError: jsxRuntime is not defined.
  • { mode: 'automatic', jsxImportSource: 'preact' } fails the build: Export "jsx" is not defined in module "node_modules/preact/dist/preact.mjs".

With the config above:

$ npx rollup -c
src/widget.jsx → dist/clock.rollup.js...
created dist/clock.rollup.js in 273ms

Add rollup -c -w as a watch script if you want rebuilds on save.

How big is it, and what about React?

I built the same clock as a React 19.3 component (createRoot instead of render, className instead of class). Then I built that unchanged React source a second time with esbuild's alias flags, which swap React for preact/compat:

npx esbuild src/widget-react.jsx --bundle --minify --format=iife --global-name=ClockWidget --jsx=automatic \
  --alias:react=preact/compat --alias:react-dom=preact/compat --outfile=dist/clock.compat.js
BundleMinifiedgzip -9
Preact 11, esbuild13,996 B5,964 B
Preact 11, Rollup + terser13,380 B5,608 B
React 19.3 + react-dom/client, esbuild223,921 B69,255 B
Same React source, aliased to preact/compat17,216 B7,241 B

All four bundles passed the same check: mount, re-render after a second, unmount to an empty element. For a widget you embed in someone else's page, the React version is more than ten times the download. The alias approach is the interesting middle ground: you can write ordinary React code, with React's types and docs, and still ship about 7 kB.

Two things to know about the React build. Pass --define:process.env.NODE_ENV=\"production\", or esbuild bundles React's development build. And createRoot renders asynchronously, so the element is still empty right after mount() returns. Preact's render is synchronous.

Which one?

  • esbuild if the widget is the whole project. It's one dependency, builds in milliseconds and has a dev server built in.
  • Rollup if you already use it, or you also publish the code as an npm library. Its output was a few hundred bytes smaller here.
  • If the host page only supports modern browsers and you control the script tag, --format=esm with <script type="module"> works too. You then call import { mount } from './clock.js' instead of using a global.

Sources & further reading

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.