Skip to main content
Create production extension artifacts for one or more browser targets. build compiles your extension in production mode and writes output to dist/<browser>. For monorepo/submodule projects, see Environment variables for configuration-time env resolution (project root first, then workspace-root fallback).

When to use build

  • Preparing extension packages for Chrome Web Store, Edge Add-ons, or Firefox Add-ons.
  • Running continuous integration (CI) jobs that produce repeatable production artifacts.
  • Validating production bundle output and browser-target differences before submission.

Build command capabilities

Usage

Build output

After running build, Extension.js generates optimized files for the selected browser targets. Output goes to dist/ with one subfolder per target. Each folder contains bundled JavaScript, CSS, HTML, and required runtime assets.
For TypeScript projects, build also regenerates the extension-env.d.ts ambient type declarations (the same file dev writes), so a CI tsc --noEmit stays clean whether or not you ran dev first. JavaScript-only projects skip this step.
Example output structure:

Browser target matrix

What engine targets mean for build

build never launches a browser, so engine targets don’t point at a binary here, but they still produce a distinct artifact, not a renamed copy of a named-target build:
  • Own output folder. --browser=chromium-based writes to dist/chromium-based, the same folder dev, preview, and start use for that target, so a project developed against a custom Chromium binary builds to matching paths.
  • Own env resolution. .env.chromium-based and .env.chromium-based.production win over the family’s .env.chromium/.env.chrome/.env.edge, and bundled code sees EXTENSION_BROWSER === "chromium-based", so code and config can branch on “generic Chromium” vs a specific store build.
  • Own manifest prefix. chromium-based: keys in manifest.json resolve as the most-specific match for this target, on top of the family-wide chromium: keys. chrome: and edge: keys do not apply.
gecko-based works the same way relative to firefox. No browser binary is required, and --chromium-binary/--gecko-binary only matter for commands that launch a browser.

Arguments and flags

--author and --author-mode are hidden, deprecated aliases for --debug.

Safari flags

These flags apply to safari and webkit-based targets only. Passing any of them with another target exits with an error, so a typo never no-ops silently. Safari packaging runs a preflight before the build. On a non-macOS host, build warns and skips the Safari packaging step but still compiles the bundle. On macOS with a broken or missing Xcode, the failure is fatal.

Shared global options

Also supports Global flags.

Mode override

--mode overrides the bundler mode and NODE_ENV for the build. Accepts development, production, or none. Use it to mirror Vite/webpack workflows where you need a non-production bundle for staging or debugging.
Invalid values exit with an error. The default remains production. A development-mode build is shippable. It keeps the CSP and permissions you wrote in the manifest, injects no reload client, and its zip carries no source maps (the .map files stay in dist/<browser> for you). Only extension dev turns the dev instrumentation on. --zip packages the output in any mode, not only production.

Zip behavior

Each zip lands in dist/, beside the dist/<browser> folder rather than inside it, because dist/<browser> is what a store upload or a load-unpacked takes whole. Without --zip-filename, the name is the manifest name lowercased with every character outside a-z0-9 and spaces removed, remaining spaces turned into dashes, then the manifest version and the browser. A manifest named My Extension+ at version 1.0.0 packages as dist/my-extension-1.0.0-chrome.zip. Because the name is rewritten, read the emitted path from the build output instead of composing it from the manifest name. Under --output json, each archive is listed in zip_artifacts with its path and size. --zip-filename names both archives, and the browser is always appended to the distribution archive, so two browsers built with the same name in separate runs never write over each other. Passing --zip-filename=review-bundle.zip with --zip-source on a Chrome build writes dist/review-bundle-chrome.zip and dist/review-bundle-source.zip. The source archive is the same for every browser, so its name carries none, and one build writes one source archive. A name that already ends in the browser is left alone. Building --browser=edge,chrome --zip-filename=my-extension.zip writes dist/my-extension-edge.zip and dist/my-extension-chrome.zip.

Examples

Building with zip output and custom filename

In this example, the build targets Edge and Chrome and zips both outputs. The archives are saved as dist/my-extension-edge.zip and dist/my-extension-chrome.zip, because the browser is appended to every distribution archive, even when you build one target.

Building with polyfill support

In this example, the build targets Chrome and Firefox and includes polyfill support where relevant.

Building source and artifact zip

What a successful build prints

After the asset summary, a successful build prints the output directory and its size, then a link you can use to share the build for review:
Each target prints its own pair of lines, so a multi-browser build repeats them once per browser. Builds that succeed with warnings print the warning details above those lines, and the compile line reads compiled with warnings instead of compiled in. Do not gate CI on this prose. It is written for people and it changes between releases. Use the exit code, or --output json below, which is the supported machine contract.

Machine output with --output json

--output json prints one schema-1 envelope on stdout and routes the human build lines to stderr. Stdout stays parseable as a single JSON document.
  • A successful run prints a status: "built" frame. Its value carries the built browsers, the resolved mode, and one summary per browser. Each summary records the output path, asset totals, warning text, and the Safari app identity when relevant. Each warning string opens with E_CODE: when a code resolves, for example E_CSS_DEAD_REF: ..., the same convention logs and uninstall use.
  • A failed build prints one ok: false frame with status: "build-failed" before the process exits 1. A compile error carries error.code: "E_COMPILE", and error.details lists each diagnostic behind it with its own code, file, and position, errors first, capped at 20 with truncated: true when cut. See Compile diagnostics. A missing project folder carries E_PROJECT_NOT_FOUND, a folder with no manifest carries E_MANIFEST_NOT_FOUND, and a manifest that does not parse carries E_MANIFEST_INVALID. A config file that fails to load carries E_CONFIG_LOAD.
  • When the path argument is a remote URL, the download progress lines go to stderr as well. A reply that is not a ZIP archive, a damaged one, or one with an entry outside its folder carries E_REMOTE_ZIP_INVALID, a timeout carries E_REMOTE_FETCH_TIMEOUT, and a refused connection or an HTTP error carries E_REMOTE_DOWNLOAD. An archive with an entry outside its folder is refused before anything is written, and its message names that entry.
Every build also writes dist/extension-js/<browser>/build-summary.json. Scripts that shell out to extension build can read structured warnings there. Guard against stale files by checking the file’s modification time.

Store check for Firefox builds

From 4.1.18, a production build for a Gecko target (firefox, gecko-based, and the Gecko forks) runs addons-linter over dist/<browser> when that package is installed in your project. The linter is what addons.mozilla.org runs on a submission, so the check surfaces a rejection before you upload. Every finding prints as a warning. The build never fails because of one, and the exit code stays 0. A summary line comes first, then one line per finding with the linter’s code, message, and location:
The output is capped at 20 findings, and the linter gets 10 seconds. Past 20, one line names how many more there are and points at npx addons-linter dist/firefox for the full report. A linter that runs past 10 seconds is dropped, and the build goes on without the check. A finding in a chunk that bundles a dependency is attributed, because the file name alone can point at the wrong author. The line ends with - this file is bundled dependency code (react, react-dom), not yours when nothing of yours is in that chunk, or - this file also bundles react, react-dom, so the finding may be theirs when the chunk mixes both. When addons-linter is not installed, the build prints one info line per project and skips the check:
The install command is phrased for the package manager that your project uses. Turn the check off with --no-addon-lint, or with commands.build.addonLint: false in extension.config.js. Non-production modes skip it, because a dev artifact carries dev-only grants that the linter would flag for nothing. extension start skips it too, since start previews a build rather than shipping one.

Readable output for Opera Add-ons

Opera’s acceptance criteria require code the reviewers can read: an extension whose own code is minified or obfuscated is rejected, while third-party libraries may stay minified. So extension build --browser=opera in production mode does not minify, and prints one info line:
The build turns minification off for the whole bundle, dependencies included, because a bundled chunk can mix your code with theirs. Pass --minify to override it, or set commands.build.minify in extension.config.js. The same option works the other way for every other target: --no-minify keeps a Chrome or Firefox build readable when a reviewer asks for it.

Best practices

  • Check build logs: Review logs for warnings and missing assets after each build.
  • Optimize your manifest: Keep manifest.json compatible with every target browser.
  • Name artifacts intentionally: Use --zip-filename for stable CI artifact naming.
  • Validate target output: Check each dist/<browser> folder before publishing. A later dev session overwrites that same folder with a dev-instrumented build that adds permissions such as scripting, tabs, management, and storage, unions the match patterns of your content scripts into host_permissions, and lists hot/* and extension-js-control.json as web-accessible resources for those same matches. A project with no content scripts gets neither of the last two. Run build again before you package or publish.

Next steps