Build Tooling & Bundlers
Build a Manifest V3 extension with a modern toolchain — bundling multiple entry points, TypeScript, per-browser manifests, development reload, and the output constraints stores enforce.
A web application has one entry point and one runtime. An extension has four or five — a service worker, one or more content scripts, a popup, an options page, perhaps an offscreen document and a side panel — and each runs under different rules. The worker must not touch the DOM, content scripts cannot use ES module imports from the manifest, extension pages forbid inline script, and every file the manifest names must exist at exactly that path in the output. Bundler defaults are designed for none of this. This section covers configuring a build that respects each context, sitting inside Manifest V3 Architecture & Extension Lifecycle.
The payoff is considerable. A well-configured build gives you TypeScript across every context, a shared module graph without duplicated code, one source tree that emits correct packages for Chrome, Firefox and Safari, and a development loop that reloads the extension on save. A poorly configured one produces the class of bug where the popup works, the worker fails to register, and the error names a chunk file you did not write.
Prerequisites checklist
- Node 20 or later, with a lockfile committed so the build is reproducible across machines and CI.
- A clear list of every context the extension uses: worker, content scripts, each extension page.
- Knowledge of which browsers you ship to — it decides whether the worker can be a module and which manifest keys are needed.
- Paths in the manifest that you are prepared to have generated, not hand-maintained.
- A decision on source maps: generated for error reporting, never shipped in the package.
- A CI job that builds from a clean checkout, because “works on my machine” is especially common with extension output paths.
Manifest registration
The manifest is the build’s contract: every path it names must exist in the output, with the right format.
1{
2 "manifest_version": 3,
3 "name": "Reader",
4 "version": "2.4.0",
5 "background": {
6 "service_worker": "service-worker.js", // emitted as an ES module
7 "type": "module"
8 },
9 "content_scripts": [{
10 "matches": ["https://*.example.com/*"],
11 "js": ["content/main.js"], // emitted as ONE self-contained classic script
12 "run_at": "document_idle"
13 }],
14 "action": { "default_popup": "popup.html" }, // HTML entry, its own bundle
15 "options_ui": { "page": "options.html", "open_in_tab": true },
16 "web_accessible_resources": [{
17 "resources": ["content/lazy/*.js"], // chunks a content script imports at runtime
18 "matches": ["https://*.example.com/*"]
19 }]
20}
Execution context: parsed by the browser at install time, and ideally generated by the build rather than edited by hand. The comments mark the format each path must have in the output — the single most common build bug is a content script emitted as an ES module with import statements, which the manifest cannot load.
1. Treat each context as its own build target
The constraints differ enough that one bundler configuration rarely fits all of them. The worker and extension pages can share chunks because both are loaded as modules from the extension origin. Content scripts cannot: a manifest-declared content script is a classic script, so it must be a single self-contained file.
1// build targets, conceptually
2const TARGETS = {
3 worker: { entry: "src/service-worker.ts", format: "es", dom: false, chunks: true },
4 pages: { entry: "src/pages/*.html", format: "es", dom: true, chunks: true },
5 content: { entry: "src/content/main.ts", format: "iife", dom: true, chunks: false },
6};
Execution context: the build configuration, in Node. Vite, webpack, Rollup and esbuild can all express this; the specific configuration is in bundling an MV3 extension with Vite and webpack configuration for MV3 extensions.
2. Keep the worker free of DOM code
A shared chunk that references document or window at module scope throws the moment the worker evaluates it, and the error names the chunk rather than the import that pulled it in. The structural fix is a directory rule: code the worker may import lives in a directory that never imports DOM code.
1# A cheap guard in CI: the worker's output must not mention the DOM.
2node -e "
3 const fs = require('fs');
4 const src = fs.readFileSync('dist/chrome/service-worker.js', 'utf8');
5 if (/\\b(document|window)\\./.test(src)) { console.error('DOM reference in worker bundle'); process.exit(1); }
6"
Execution context: your shell, in CI after the build. It only inspects the entry file; extend it to every chunk the worker imports if your bundler splits the worker graph. The directory discipline is described in sharing code between popup, options and side panel.
3. Generate the manifest per target
Chrome, Firefox and Safari disagree on a handful of manifest keys: the background declaration, browser_specific_settings, some permission names, and whether update_url is allowed. Generating the manifest from one typed source removes a whole class of drift.
1// build/manifest.mjs
2export function manifest(target, pkg) {
3 const m = {
4 manifest_version: 3,
5 name: "Reader",
6 version: pkg.version,
7 permissions: ["storage", "alarms", ...(target === "chrome" ? ["offscreen", "sidePanel"] : [])],
8 background: target === "firefox"
9 ? { scripts: ["service-worker.js"], type: "module" }
10 : { service_worker: "service-worker.js", type: "module" },
11 };
12 if (target === "firefox") m.browser_specific_settings = { gecko: { id: "reader@example.com" } };
13 return m;
14}
Execution context: the build, in Node. Reading version from package.json means one version number for everything, which matters when error reports are bucketed by version. The full pattern is in generating a manifest per browser target.
4. Make development fast without shipping development code
The development loop — edit, rebuild, reload the extension, reopen the popup — is slow by default. Watch mode plus an automatic extension reload brings it down to a second or two. The reload client must never reach a release build, because it opens a WebSocket to localhost and stores will flag it.
1// Only in development builds; dead-code-eliminated otherwise.
2if (import.meta.env.DEV) {
3 const ws = new WebSocket("ws://localhost:35729");
4 ws.onmessage = (e) => { if (e.data === "reload") chrome.runtime.reload(); };
5}
Execution context: the service worker in a development build. chrome.runtime.reload() restarts the whole extension, which also re-runs onInstalled — useful for exercising migrations, and a reason to guard first-run behaviour against repeated installs during development. The complete setup is in hot reloading an extension during development.
5. Choosing a toolchain
The choice of bundler matters less for an extension than it seems, because every mainstream option can produce correct output once configured. What matters is how much configuration it takes, how legible the output is, and how fast the development loop runs.
Vite is the natural default for new projects: fast watch builds, first-class TypeScript, and a configuration that handles the worker and extension pages in one pass, with content scripts built in a second, library-mode pass. webpack remains a sound choice for existing projects that already use it; its extension configuration is well understood, and its per-entry control is precise once the eval-based development defaults are replaced. esbuild or Rollup directly suit small extensions that want minimal tooling. Community plugins that read the manifest and derive the build from it can remove most configuration, at the cost of depending on the plugin keeping pace with both the bundler and browser changes. The Vite and webpack configurations are covered in bundling an MV3 extension with Vite and webpack configuration for MV3 extensions.
6. Verifying the output, not the configuration
Build configuration is easy to get almost right. The failures that reach users are details of the output: a content script emitted with an import statement, a worker chunk that references document, an inline script injected into an HTML page, a source map shipped in the package, a manifest path that does not match an emitted file. None of these is visible from the configuration file, and all of them are visible in the output.
The dependable approach is a short script that runs after every build and checks the output directly. Confirm every path the manifest names exists. Confirm content scripts contain no module syntax. Confirm the worker’s files mention no DOM globals. Confirm no HTML file contains an inline <script>. Confirm no .map file or sourceMappingURL comment remains. Each check is a few lines; together they turn the most common build mistakes into failed builds rather than rejected submissions or blank popups.
7. Reproducibility and review
Firefox’s add-on reviewers rebuild minified extensions from source and compare the result with the uploaded package, and a build that is not reproducible delays or fails that review. Reproducibility is also simply good practice: the build that produced a release should be recreatable exactly, months later, to investigate a bug in it.
Four habits cover it. Commit the lockfile and install with npm ci. Pin the Node version in the repository and in CI. Avoid output that depends on the environment — timestamps, machine names, absolute paths — and derive any build identifier from the commit instead. And keep the build instructions in the repository, short enough for a reviewer to follow verbatim, with checksums of the output files so a matching rebuild can be confirmed at a glance.
8. Multiple targets without multiple codebases
Supporting Chrome, Firefox and Safari should mean one source tree and one build command that emits one package per target, not three branches that drift apart. The differences between targets are small and specific — the background declaration, Firefox’s add-on id, a few Chrome-only permissions, whether an offscreen document exists — and belong in two places only: a manifest generator with a small function per target, and runtime capability checks for the handful of APIs that differ. Everything else is shared. The generator approach is described in generating a manifest per browser target.
A useful discipline is to print, on every build, the list of manifest keys that differ between targets and compare it with an expected snapshot. A new difference then shows up in review as a deliberate change, rather than being discovered by users of one browser.
9. Keeping development and release apart
Development builds need things release builds must never contain: a reload client connected to a local server, verbose logging, test hooks exposed on the worker’s global object, readable unminified code with inline source maps. Every one of these is a problem if it ships — a review finding, a security risk, or both.
Guard them with build-time constants that the bundler replaces, so the development branches are removed as dead code in release builds rather than merely skipped at runtime. Then verify their absence with a check against the release output: search for the reload port, the test-hook names and sourceMappingURL, and fail the build if any appear. The reload setup itself is in hot reloading an extension during development.
10. Types across contexts
TypeScript adds most value in an extension at the boundaries between contexts — messages, storage shapes, the manifest — and least if every context shares one configuration that allows both DOM and worker globals. Splitting the project into per-context configurations, with a shared project that has neither, turns the most expensive cross-context mistakes into compile errors: DOM code reaching the worker, UI code reaching a content script, a renamed message nobody updated. The setup is in TypeScript project setup for extensions.
11. Watching size over time
Build output grows quietly. A convenience dependency here, a polyfill there, and a content script that started at four kilobytes is forty a year later — parsed into every page it matches. Record the size of each entry point on every build, set a budget per entry with the tightest one on content scripts, and fail the build when a budget is exceeded. The failure message should name the entry and the size, so the pull request that caused the growth is obvious and the conversation about whether it is worth it happens at review time rather than after users notice.
Budgets work best when they are set slightly below today’s real sizes and lowered as improvements land, rather than set at an aspirational number that fails immediately and gets disabled. A ratchet like that keeps every entry point at least as small as it is today, and makes each reduction permanent once it has been achieved.
Publish the size report as a build artifact as well, so the trend across releases is visible at a glance and a slow drift — a few hundred bytes per release, never enough to trip a budget on its own — can be spotted and addressed before it adds up.
MV3 constraints box
- No remote code. Nothing in the output may load script from a URL. CDNs, remote
import()andeval-based loaders are all out. - No inline script in extension pages. Bundlers that inject an inline bootstrap
<script>into HTML must be configured to emit external files. - Paths are exact. The manifest names files; the output must contain them at exactly those paths, with case preserved.
- Content scripts are classic. A content script listed in the manifest cannot use
import. Bundle it to one file, or load extra code with a runtimeimport()of a web-accessible file. - Readable output. Minification is allowed; obfuscation is not. Firefox reviewers require the source and build instructions for any minified output.
- No source maps in the package. They contain the original source and any secrets inlined into it.
Cross-browser notes
| Concern | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| Background declaration | service_worker + type: module | scripts + type: module (121+) | service_worker |
| Extension id in manifest | key (optional) | browser_specific_settings.gecko.id | Bundle identifier in Xcode |
| Minified code | Accepted | Source + build steps required | Accepted |
| Packaging | ZIP upload | web-ext build, then upload | Xcode converter → app |
| Development reload | chrome.runtime.reload | web-ext run auto-reload | Rebuild the app |
What this section covers
The guides go from the bundler up: bundling an MV3 extension with Vite and webpack configuration for MV3 extensions cover the two most common toolchains; TypeScript project setup for extensions covers per-context type checking; generating a manifest per browser target handles the cross-browser output; and hot reloading an extension during development makes the loop fast.
Related
- Service worker fundamentals — the runtime the worker bundle targets.
- Content scripts and DOM injection — why content-script output is different.
- Cross-browser API compatibility — the runtime half of multi-target builds.
- CI and release automation — running this build in a pipeline.
- Manifest V3 Architecture & Extension Lifecycle — the parent section.