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.

Published September 18, 2026 Updated September 18, 2026 8 min read
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

Webpack defaults and the context each one breaksFour webpack defaults — eval source maps, JSONP chunk loading, global splitChunks and hashed entry names — mapped to the extension context each breaks.devtool: eval-*development defaultCSP blocks eval everywhereJSONP chunk loadinginjects <script> tagsno document in the workersplitChunks: allshares code across entriescontent scripts cannot import[contenthash] entry namescache-bustingmanifest paths are fixed
The first one breaks every context in development; the others break one context each, in production too.

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.

How each entry leaves webpackThe worker and content script are emitted as single self-contained files, popup and options share a chunk and get generated HTML, and static assets are copied unchanged.service-workerone file, modulecontent/mainone file, IIFEpopup + optionsentries + shared chunkalongsideHtmlWebpackPluginexternal module scriptsCopyPluginicons, _localeshidden-source-mapmaps not referenced
Only the extension pages use split chunks — the two fragile contexts get one file each.

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, not npm 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" and chunkIds: "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.

devtool settings and whether they load in an extensionFive webpack devtool values compared on whether they use eval, whether the extension loads under MV3 CSP, and their suitability for development and release.devtoolUses evalLoads under MV3Use foreval-cheap-module-source-mapYesNoNeverevalYesNoNevercheap-module-source-mapNoYesDevelopmentsource-mapNoYesDevelopmenthidden-source-mapNoYesRelease (upload maps)
Anything with eval in its name fails to load at all — which is most of webpack's development presets.

Cross-browser variation

  • Chrome / Edge: a module worker with outputModule works; so does a single classic file. Both load identically once the manifest’s type matches.
  • 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 and target: ["webworker", "es2022"] for the worker so webpack does not emit window references into the worker bundle.

Verification

  1. Build in development mode and load unpacked. If the worker fails with a CSP error mentioning eval, the devtool is still an eval-* variant.
  2. 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.

  1. Open the popup and options page with DevTools and confirm no CSP violations.
  2. 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.

Other MV3 Architecture & Extension Lifecycle Resources