Bundling with esbuild or Rollup
Build an MV3 extension with a small esbuild or Rollup script: one bundle per entrypoint, ESM for the service worker, IIFE for content scripts, copying static files, watch mode, source maps and per-browser manifests.
Table of Contents
Not every extension needs a framework. A service worker, a content script and a popup can be built by a forty-line script around esbuild — fast enough that watch-mode rebuilds feel instant, simple enough that every line is understood, and with nothing between your code and the browser. Rollup gives finer control over output when you need it. The catch is that extensions have bundling rules web apps do not: content scripts cannot be ES modules, the service worker must not use dynamic import(), every page is its own entrypoint, and static files must be copied alongside. This guide writes a build script that gets those rules right. It belongs to build tooling and bundlers.
The output formats each context needs
Each extension context loads code differently, and the bundle format must match. The service worker can be an ES module if the manifest says "type": "module", so ESM output works and keeps static imports; dynamic import() is not allowed in service workers, so everything it uses must be bundled in or statically imported. Content scripts are injected as classic scripts — they cannot use import statements at all — so they need IIFE output with all dependencies inlined. Extension pages (popup, options, side panel) are ordinary HTML documents and can load ES modules with <script type="module">, including code-split chunks. A build that emits one format for everything gets one of these wrong.
Step-by-step: an esbuild build script
1. Lay out sources by context
1src/
2 background/sw.ts
3 content/article.ts
4 popup/popup.html, popup.ts, popup.css
5 options/options.html, options.ts
6 shared/ ← imported by any of the above
7static/
8 icons/16.png …
9 _locales/en/messages.json
10manifest.base.json
Execution context: the repository. Keeping each context’s entry in its own folder makes the build configuration a simple list, and shared/ code is bundled into whichever context imports it. Static files that need no processing — icons, locales — live apart so they can be copied verbatim.
2. Build each context with the right format
1// build.mjs
2import * as esbuild from "esbuild";
3import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
4
5const target = process.argv.includes("--firefox") ? "firefox" : "chrome";
6const watch = process.argv.includes("--watch");
7const out = `dist/${target}`;
8const common = { bundle: true, sourcemap: "linked", target: ["chrome116", "firefox121"], logLevel: "info",
9 define: { "import.meta.env.TARGET": JSON.stringify(target) } };
10
11const builds = [
12 { ...common, entryPoints: ["src/background/sw.ts"], outfile: `${out}/sw.js`, format: "esm" },
13 { ...common, entryPoints: ["src/content/article.ts"], outfile: `${out}/content/article.js`, format: "iife" },
14 { ...common, entryPoints: ["src/popup/popup.ts", "src/options/options.ts"], outdir: out, outbase: "src",
15 format: "esm", splitting: true, chunkNames: "chunks/[name]-[hash]" },
16];
Execution context: Node at build time. Three builds, three formats: ESM for the worker, IIFE for the content script, ESM with code splitting for pages (esbuild’s splitting requires ESM and outdir). The define constant lets code branch on the target browser at build time, with dead branches removed by minification. target pins syntax to the oldest browsers you support.
3. Copy static files and HTML
1await mkdir(out, { recursive: true });
2await cp("static", out, { recursive: true });
3for (const page of ["popup/popup.html", "popup/popup.css", "options/options.html"]) {
4 await cp(`src/${page}`, `${out}/${page}`);
5}
Execution context: Node at build time. HTML files reference their scripts with <script type="module" src="popup.js"> relative to their own folder, matching where outbase: "src" places the bundles. CSS imported from TypeScript is emitted by esbuild next to the JS; plain CSS files referenced from HTML are copied.
4. Generate the manifest per target
1const base = JSON.parse(await readFile("manifest.base.json", "utf8"));
2const manifest = target === "firefox"
3 ? { ...base, background: { scripts: ["sw.js"], type: "module" },
4 browser_specific_settings: { gecko: { id: "readable@acme.example", strict_min_version: "121.0" } } }
5 : { ...base, background: { service_worker: "sw.js", type: "module" } };
6manifest.version = JSON.parse(await readFile("package.json", "utf8")).version;
7await writeFile(`${out}/manifest.json`, JSON.stringify(manifest, null, 2));
Execution context: Node at build time. A base manifest plus small per-target overrides keeps differences visible. Taking the version from package.json gives one source of truth for releases. More options are in generating a manifest per browser target.
5. Add watch mode
1if (watch) {
2 for (const b of builds) await (await esbuild.context(b)).watch();
3 console.log(`watching → ${out} (reload the extension to pick up changes)`);
4} else {
5 await Promise.all(builds.map((b) => esbuild.build({ ...b, minify: true })));
6}
Execution context: Node. esbuild’s incremental contexts rebuild in milliseconds. Reloading the extension remains manual unless you add a reload helper; see hot reloading an extension during development. Minify only production builds so stack traces stay readable in development.
6. The same with Rollup, when you need finer control
1// rollup.config.mjs
2import { nodeResolve } from "@rollup/plugin-node-resolve";
3import typescript from "@rollup/plugin-typescript";
4export default [
5 { input: "src/background/sw.ts", output: { file: "dist/chrome/sw.js", format: "es" }, plugins: [nodeResolve(), typescript()] },
6 { input: "src/content/article.ts", output: { file: "dist/chrome/content/article.js", format: "iife" }, plugins: [nodeResolve(), typescript()] },
7];
Execution context: Node at build time. Rollup is slower than esbuild but produces cleaner output and has a mature plugin ecosystem — useful for unusual asset handling or when you need precise control over chunking. The format rules are identical.
7. Keep source maps out of the store package
Linked source maps help debugging but expose your source to anyone who unpacks the extension, and web-accessible source maps can be fetched by pages. Generate them for development and for upload to your error tracker, and exclude *.map from the zip you submit — see uploading source maps for readable stack traces.
Common mistakes
- ESM content scripts. They fail with “Cannot use import statement outside a module”; use IIFE.
- Dynamic
import()in the worker. Service workers reject it; import statically. - Code splitting the content script. It must be a single self-contained file.
- Hand-maintained manifests per browser. Generate them from one base.
- Shipping source maps. Strip them from store packages.
Cross-browser variation
- Chrome / Edge: ESM service worker with
"type": "module"; targetchrome116or your minimum. - Firefox: the background is an event page; with
"type": "module"inbackground, ESM works there too. Older Firefox versions without module support need an IIFE background. - Safari: module service workers are supported in recent versions; keep the content-script IIFE rule.
Verification
- Build and load
dist/chromeunpacked: no errors onchrome://extensions. - Check
dist/chrome/content/article.jscontains noimportstatements. - Search
sw.jsforimport(: there should be none. - Build
--firefoxand load in Firefox; confirm the background starts.
FAQ
Why not use one build with multiple entry points?
esbuild applies one format per build. Separate builds per format are simpler than post-processing.
Can content scripts share code with the worker?
Yes — import from shared/; each bundle gets its own copy, which is fine for small modules.
How do I handle images imported from code?
Use esbuild’s loader: { ".png": "file" } to emit them and reference them through chrome.runtime.getURL.
When should I move from a script like this to a framework?
When the script starts to grow features a framework already has — automatic reload of the extension, per-browser manifests with many differences, content-script UI helpers, zip packaging for several stores. Until then, a short script you fully understand is easier to maintain than a dependency you do not.
How do I add TypeScript type-checking?
esbuild strips types without checking them. Run tsc --noEmit alongside the build (or in CI) so type errors fail the pipeline even though the bundle builds.
Should the build clean the output folder first?
Yes. Stale files from a previous build — a renamed content script, an old chunk — end up in the zip and can be loaded by mistake. Delete dist/<target> at the start of every production build.
Related
- Code splitting and dynamic imports in MV3 — the splitting rules in depth.
- Webpack configuration for MV3 extensions — the webpack equivalent.
- Building reproducible release zips — packaging the output.
- Build tooling and bundlers — the parent topic.