Bundle a Preact (or React) app into one standalone script with esbuild or Rollup
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 atpreact.umd.jsfrom 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. defaultPropsand automaticpxsuffixing for numeric styles moved from core intopreact/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 esbuildsrc/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 13msWhat each flag does:
--bundleinlines Preact and everything else the widget imports.--format=iifewraps the result in a function that runs as soon as the script loads. That's what a classic<script>tag expects.--global-name=ClockWidgetputs the module's exports onwindow.ClockWidget, so the page can callClockWidget.mount(…).--jsx=automatic --jsx-import-source=preactcompiles JSX to calls intopreact/jsx-runtime. You don't need to importhin 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-terserrollup.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 importsreact/jsx-runtime. Rollup only warns that it's an unresolved dependency and guesses a global for it, so the bundle crashes in the browser withReferenceError: 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 273msAdd 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| Bundle | Minified | gzip -9 |
|---|---|---|
| Preact 11, esbuild | 13,996 B | 5,964 B |
| Preact 11, Rollup + terser | 13,380 B | 5,608 B |
| React 19.3 + react-dom/client, esbuild | 223,921 B | 69,255 B |
Same React source, aliased to preact/compat | 17,216 B | 7,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=esmwith<script type="module">works too. You then callimport { mount } from './clock.js'instead of using a global.