Skip to main content
Many extensions carry their own webpack.config.js or vite.config.ts, with one entry per script and a copy step for the manifest. Extension.js turns that around: manifest.json is the source of truth, and the build reads every entry from it. This guide maps a hand-written config to Extension.js and lists the problems that came up in real migrations. If your project uses WXT, CRXJS or Plasmo, start from Compare and migrate instead.

What changes, what stays

Stays the same: your source files, your UI components, your tests, and your chrome.* or browser.* API calls. Changes:
  • The entry map in your config goes away. The manifest and the special folders name every entry.
  • The output folder is always dist/<browser>, one folder per browser target.
  • Loaders and plugins move into extension.config.js.
  • DefinePlugin constants and .env values become EXTENSION_PUBLIC_* variables.
  • webpack serve or vite build --watch become extension dev.

Step 1: install Extension.js

Keep the loaders that your code still needs, such as a Sass or SVG loader. Remove webpack, webpack-cli, vite and the plugins that only wired up the old build.

Step 2: let the manifest own the entries

Point every manifest field at a source file. TypeScript, JSX and framework files are fine there, because the build compiles them and rewrites the paths in the output. The CopyWebpackPlugin step that moved manifest.json and icons into the output is not needed. The build emits the manifest, the icons and the _locales folder on its own.

Step 3: move loaders and plugins

Create extension.config.js at the project root. The config hook receives the generated Rspack configuration, and you patch it the way you patched webpack.
Most webpack loaders run unchanged. Plugins that reach into webpack internals may not, so prefer the Rspack version of a plugin when one exists. See Rspack configuration for the full surface.

Step 4: update package.json scripts

extension dev opens a browser with the extension loaded and reloads on save. See Reload and HMR for what reloads and what does not.

Step 5: move environment variables

Rename the variables in your .env files to the EXTENSION_PUBLIC_ prefix. Read them as process.env.EXTENSION_PUBLIC_API_URL or import.meta.env.EXTENSION_PUBLIC_API_URL, both work. Variables without the prefix never reach extension code. See Environment variables.

Step 6: verify

Load dist/chrome as an unpacked extension and compare it with the old output. For Firefox, the build warns when the manifest lacks browser_specific_settings.gecko.data_collection_permissions. New add-ons on addons.mozilla.org need that key, so add it before you submit. See Multi-platform builds for the exact field.

Problems that cost real migrations time

Each of these came up at least once while moving existing extensions. None is a defect in your project, and each has a short answer.

A dynamic import of a runtime URL

Code like import(chrome.runtime.getURL("worker.js")) asks the bundler to resolve a string that only exists in the browser. Mark it so the bundler leaves it alone:
Put the target file in public/ so that it ships unchanged, and list it under web_accessible_resources if a web page loads it.

A bundle that must exist before the main build

Some extensions inline one compiled script into another as a string, for example a script that is injected into the page document. The inner file has to be built first. Add a plugin that runs a separate compiler before each build, in both the beforeRun and watchRun hooks:
The main build then imports .inline/document.js as a raw string with type: "asset/source".

A custom loader file is not watched

A loader that you wrote yourself and reference from extension.config.js is not part of the watch set. After you edit the loader, restart extension dev.

Pug or other HTML templates

Extension.js ships no Pug loader. Choose one of two paths. Render the templates to static HTML once and commit the result. Or add a loader rule for .pug files in the config hook.

A localized name in the terminal

If manifest.json names the extension __MSG_extensionName__, the terminal card prints that placeholder as written. The browser shows the translated name. Nothing is wrong with the build.

Native dependencies and npm 12

Since npm 12, npm install skips the install scripts of dependencies unless you approve them. A package such as canvas or pngquant-bin then installs without its binary, and the build fails later with ENOENT. Approve the packages that need their scripts:
The command records them under allowScripts in package.json. Extension.js also installs missing dependencies on its own with --ignore-scripts. Set EXTENSION_ALLOW_INSTALL_SCRIPTS=true when that step must run the scripts too.

See also