Webpack Configuration for MV3 Extensions
Configure webpack 5 for a Manifest V3 extension — per-entry targets, a module or single-file worker, splitChunks that excludes content scripts, eval-free devtool and copied static assets.
Table of Contents
Webpack’s defaults were written for web applications and three of them break an MV3 extension outright: the development devtool uses eval, which extension CSP forbids; the runtime chunk loader injects <script> tags, which a service worker cannot use; and splitChunks happily pulls content-script code into a shared chunk the manifest cannot load. A working configuration turns each of those off in the right place. This guide is part of build tooling and bundlers.
The three defaults that break
Step-by-step
1. Declare entries and stable output names
1// webpack.config.js
2const path = require("node:path");
3
4module.exports = (env, argv) => ({
5 mode: argv.mode ?? "production",
6 entry: {
7 "service-worker": "./src/service-worker.ts",
8 "content/main": "./src/content/main.ts",
9 popup: "./src/pages/popup.ts",
10 options: "./src/pages/options.ts",
11 },
12 output: {
13 path: path.resolve(__dirname, "dist/chrome"),
14 filename: "[name].js", // matches manifest paths exactly
15 chunkFilename: "chunks/[name].[contenthash:8].js",
16 clean: true,
17 },
18});
Execution context: the build, in Node. Entry names containing a slash produce subdirectories, which is how content/main lands at the path the manifest names. Only chunk files carry a hash.
2. Replace the eval-based devtool
1 devtool: argv.mode === "development" ? "cheap-module-source-map" : "hidden-source-map",
Execution context: the build configuration. Any eval-* devtool wraps modules in eval() calls, and the extension fails to load in development with a CSP error that looks like a bug in your code. cheap-module-source-map writes separate map files instead; hidden-source-map produces maps for upload to an error service without adding a sourceMappingURL comment to the shipped files.
3. Pick the right chunk loading per target
The worker cannot inject <script> tags, so webpack must load chunks with importScripts (classic worker) or import() (module worker). The simplest robust setting for the worker is to not split it at all.
1 optimization: {
2 splitChunks: {
3 chunks: (chunk) => !["service-worker", "content/main"].includes(chunk.name),
4 cacheGroups: {
5 shared: { test: /[\\/]src[\\/](shared|ui)[\\/]/, name: "shared", minChunks: 2 },
6 },
7 },
8 runtimeChunk: false,
9 },
Execution context: the build configuration. Excluding the worker and the content script from splitChunks makes each a single self-contained file, which sidesteps chunk loading entirely for the two contexts where it is fragile. Popup and options still share a shared chunk, loaded through ordinary <script> tags in their HTML.
4. Generate HTML without inline script
1const HtmlWebpackPlugin = require("html-webpack-plugin");
2
3 plugins: [
4 new HtmlWebpackPlugin({ filename: "popup.html", template: "src/pages/popup.html", chunks: ["popup"], inject: "body", scriptLoading: "module" }),
5 new HtmlWebpackPlugin({ filename: "options.html", template: "src/pages/options.html", chunks: ["options"], inject: "body", scriptLoading: "module" }),
6 ],
Execution context: the build configuration. scriptLoading: "module" emits <script type="module" src="…"> tags, which are external and CSP-safe. Avoid plugins that inline a runtime or a CSS-loading snippet; with runtimeChunk: false there is no runtime to inline.
5. Copy static files verbatim
1const CopyPlugin = require("copy-webpack-plugin");
2
3 plugins: [
4 new CopyPlugin({ patterns: [{ from: "public", to: "." }] }), // icons, _locales
5 ],
Execution context: the build configuration. _locales in particular must keep its exact directory structure — _locales/en/messages.json — or chrome.i18n resolves nothing, as described in localising extension UI with the i18n API.
6. Emit the worker as a module where supported
1 experiments: { outputModule: true },
2 output: {
3 // …as before…
4 module: true,
5 chunkFormat: "module",
6 },
Execution context: the build configuration, for a Chrome target with "type": "module" in the manifest. For older Firefox targets that need a classic background script, build a separate config without outputModule — the worker is a single file either way, so the only difference is the wrapper.
Handling assets content scripts need at runtime
A content script bundled as a single file still sometimes needs a resource at runtime — a stylesheet for injected UI, an image, a WebAssembly module, or a lazily loaded chunk for a rarely used feature. Webpack’s default for such assets is a URL relative to the page, which in a content script is the host website. The request goes to https://example.com/assets/panel.css and 404s.
The fix is to resolve assets against the extension origin explicitly, and to list them as web-accessible.
1// src/content/main.ts
2__webpack_public_path__ = chrome.runtime.getURL("/");
3
4const cssUrl = new URL("./panel.css", import.meta.url); // resolved against the extension, not the page
Execution context: the content script’s isolated world. Setting __webpack_public_path__ at the top of the entry — before any import that references an asset — makes every webpack-generated asset URL point at chrome-extension://<id>/. It must be the first statement, because assets referenced before it resolve against the page.
1{
2 "web_accessible_resources": [{
3 "resources": ["assets/*", "chunks/content-*.js"],
4 "matches": ["https://*.example.com/*"]
5 }]
6}
Execution context: parsed at install. Without this entry the page’s origin cannot load the files, and the content script’s request fails even though the path is correct. Keep matches as narrow as the content script’s own — a broad pattern here lets any site probe for your extension by fetching a known asset path.
Lazily loaded content-script chunks follow the same rule and have one more requirement: the chunk must be loaded with import() from a web-accessible URL, since the manifest cannot declare it. That is the pattern for keeping the always-injected script small, discussed in declarative vs programmatic content script registration.
Keeping webpack builds reviewable
Firefox reviewers require the source and exact build instructions for any minified output, and they rebuild it to compare. A webpack build that is not reproducible — different output on the reviewer’s machine — delays review and can fail it.
Three habits make the output reproducible:
- Pin everything. Commit the lockfile and use
npm ci, notnpm install, in the build instructions. - Avoid environment-dependent output.
new webpack.DefinePlugin({ BUILD_TIME: Date.now() })makes every build differ. Derive anything like that from the git commit instead. - Use deterministic ids. Webpack 5’s production defaults —
moduleIds: "deterministic"andchunkIds: "deterministic"— produce stable output across machines; do not override them.
1# The build instructions a reviewer can follow verbatim
2node --version # v20.x
3npm ci
4npm run build:firefox
5sha256sum dist/firefox/*.js
Execution context: the build instructions you submit to AMO. Including checksums lets the reviewer confirm their rebuild matches your upload without diffing files by hand — the submission process is in publishing to Firefox add-ons and Safari.
Cross-browser variation
- Chrome / Edge: a module worker with
outputModuleworks; so does a single classic file. Both load identically once the manifest’stypematches. - Firefox: module backgrounds from 121; for older versions emit a classic single-file background. AMO requires source and reproducible build steps for minified output.
- Safari: feed the Chrome build to the Xcode converter. Avoid
import()-based lazy chunks in the worker for Safari builds; bundle the worker fully. - All three: set
target: ["web", "es2022"]for pages andtarget: ["webworker", "es2022"]for the worker so webpack does not emitwindowreferences into the worker bundle.
Verification
- Build in development mode and load unpacked. If the worker fails with a CSP error mentioning
eval, thedevtoolis still aneval-*variant. - Confirm the worker and content script are single files with no chunk loading:
1grep -c "importScripts\|__webpack_require__.e" dist/chrome/service-worker.js dist/chrome/content/main.js
Execution context: your shell. A non-zero count for __webpack_require__.e means the file still contains a chunk loader, so something is being split out of it.
- Open the popup and options page with DevTools and confirm no CSP violations.
- Rebuild on a second machine or a clean container and compare checksums.
FAQ
Should I migrate from webpack to Vite?
Not for its own sake. A working webpack configuration for an extension is stable and well understood. Migrate if build times are hurting the development loop or if you are rewriting the build anyway.
Why does the worker bundle contain window?
Because it was built with a web target. Use webworker for the worker entry, or split the worker into its own configuration.
Can webpack’s dev server work for extension pages?
Not directly — the pages load from chrome-extension://, not from the dev server. Use webpack --watch writing to disk.
Related
- Bundling an MV3 extension with Vite — the same output with Vite.
- TypeScript project setup for extensions — the loader configuration’s other half.
- Writing a strict Content Security Policy for MV3 — why eval-based output fails.
- Build tooling and bundlers — the parent guide.