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 yourchrome.*
or browser.* API calls.
Changes:
- The
entrymap 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. DefinePluginconstants and.envvalues becomeEXTENSION_PUBLIC_*variables.webpack serveorvite build --watchbecomeextension dev.
Step 1: install Extension.js
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
Createextension.config.js at the project root. The config hook receives the generated
Rspack configuration, and you patch it the way you patched webpack.
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
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 likeimport(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:
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 thebeforeRun and watchRun hooks:
.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 fromextension.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
Ifmanifest.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:
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.

