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.

Published October 2, 2026 Updated October 2, 2026 7 min read
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.

Bundle format by contextRequired or recommended output format, import support and code-splitting support for the service worker, content scripts and extension pages.ContextFormatStatic importDynamic import()Code splittingService workerESM (type: module)YesNoStatic chunks onlyContent scriptIIFENoVia getURL trickNoExtension pagesESMYesYesYes
Content scripts are the strict case: one self-contained classic script each.

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.

What the build script producesThree esbuild invocations produce an ESM service worker, an IIFE content script and code-split ESM pages; static files and HTML are copied; a manifest is generated per target from a base file.sw.ts → sw.jsesmarticle.ts → article.jsiifepages → *.js + chunksesm, splittingthen assemble the packagecopy static/icons, _localescopy *.html, *.csspagesmanifest per targetbase + overrides
One script, three formats, one dist folder per browser.

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.

Watch mode rebuildSaving a shared module triggers incremental rebuilds of every bundle that imports it; the developer reloads the extension and the new code runs.Editoresbuild contextsdist/chromesave shared/format.tsrebuild sw.jsrebuild article.jsrebuild popup chunksreload extension
Shared code fans out to every context that imports it.

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"; target chrome116 or your minimum.
  • Firefox: the background is an event page; with "type": "module" in background, 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

  1. Build and load dist/chrome unpacked: no errors on chrome://extensions.
  2. Check dist/chrome/content/article.js contains no import statements.
  3. Search sw.js for import(: there should be none.
  4. Build --firefox and 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.

Other MV3 Architecture & Extension Lifecycle Resources