Bundling an MV3 Extension with Vite
Configure Vite for a Manifest V3 extension — multiple HTML and script entries, a module service worker, self-contained content scripts, stable output names and no inline scripts.
Table of Contents
Vite is an excellent fit for extension pages and an awkward one for everything else. Out of the box it hashes filenames the manifest cannot predict, splits a content script into chunks the manifest cannot load, and in development serves modules from a dev server that extension CSP will not allow. None of that is hard to change, but all of it must be changed. This guide is part of build tooling and bundlers.
What Vite does by default, and what the extension needs
Step-by-step
1. Lay out the source tree by entry point
1src/
2 service-worker.ts
3 content/main.ts
4 pages/popup.html pages/popup.ts
5 pages/options.html pages/options.ts
6 offscreen/host.html offscreen/host.ts
7 shared/… (no DOM)
8 ui/… (DOM)
9public/
10 icons/… (copied as-is)
11 _locales/…
Execution context: the repository. Vite copies public/ verbatim into the output, which is the right place for icons and _locales — files the manifest references by fixed path and that need no processing.
2. Build the worker and pages together
1// vite.config.js
2import { defineConfig } from "vite";
3import { resolve } from "node:path";
4
5export default defineConfig({
6 build: {
7 outDir: "dist/chrome",
8 emptyOutDir: true,
9 sourcemap: "hidden", // generated for error reporting, not referenced
10 modulePreload: false, // avoids an inline polyfill script in pages
11 rollupOptions: {
12 input: {
13 "service-worker": resolve("src/service-worker.ts"),
14 popup: resolve("src/pages/popup.html"),
15 options: resolve("src/pages/options.html"),
16 offscreen: resolve("src/offscreen/host.html"),
17 },
18 output: {
19 entryFileNames: "[name].js", // service-worker.js, not service-worker-3fa1.js
20 chunkFileNames: "chunks/[name]-[hash].js",
21 assetFileNames: "assets/[name]-[hash][extname]",
22 },
23 },
24 },
25});
Execution context: the build, in Node. entryFileNames: "[name].js" is what makes the manifest’s service_worker: "service-worker.js" resolve. Chunks can keep their hashes — the manifest never names them, and the entry files import them by relative path. modulePreload: false matters: Vite otherwise injects an inline module-preload polyfill that extension CSP refuses.
3. Build content scripts separately, as one file each
1// vite.content.config.js
2import { defineConfig } from "vite";
3import { resolve } from "node:path";
4
5export default defineConfig({
6 build: {
7 outDir: "dist/chrome/content",
8 emptyOutDir: false, // do not wipe the main build
9 lib: {
10 entry: resolve("src/content/main.ts"),
11 formats: ["iife"],
12 name: "ReaderContent",
13 fileName: () => "main.js",
14 },
15 rollupOptions: { output: { inlineDynamicImports: true } },
16 },
17});
Execution context: the build, in Node, run as a second pass after the main build. lib mode with an iife format produces a single self-contained file with no import statements — the only shape a manifest-declared content script can load. Running it as a separate config is simpler than bending one config to emit both formats.
1{
2 "scripts": {
3 "build": "vite build && vite build -c vite.content.config.js && node build/manifest.mjs chrome"
4 }
5}
Execution context: package.json. Three steps, in order: pages and worker, content scripts, then the manifest generated into the output directory — the generator is described in generating a manifest per browser target.
4. Keep HTML entries free of inline script
Vite rewrites <script type="module" src="./popup.ts"> in each HTML entry to point at the built file. That is external and CSP-safe. What is not safe is anything Vite or a plugin injects inline.
1grep -l "<script>" dist/chrome/*.html && echo "inline script found" || echo "html ok"
2grep -n "<script type=\"module\">" dist/chrome/*.html || true
Execution context: your shell, against the build output. An inline <script> in a built extension page is blocked by the CSP and usually shows up as a blank popup — the failure diagnosed in debugging a popup that renders blank.
5. Use watch mode, not the dev server
vite dev serves modules over HTTP from localhost, which extension pages cannot load under MV3’s CSP. The workable development mode is a watched build writing to disk.
1{
2 "scripts": {
3 "dev": "concurrently \"vite build --watch --mode development\" \"vite build -c vite.content.config.js --watch --mode development\""
4 }
5}
Execution context: package.json, run from your shell. Load dist/chrome unpacked once; each save rebuilds the affected entry. Pair this with an automatic reload so you do not have to press the button — the setup in hot reloading an extension during development.
Plugins, and when to reach for one
Community plugins such as CRXJS and vite-plugin-web-extension automate most of the above: they read the manifest, derive the entry points from it, handle content-script output and wire up a reload. They are worth using when their assumptions match yours, and they save the two-pass configuration entirely.
The trade is transparency. When something goes wrong in a plugin-driven build — a content script emitted as a module, an unexpected inline script — the fix lives inside the plugin, and extension builds are a niche enough that plugin releases can lag Vite’s. A hand-written two-pass configuration is roughly forty lines and has no dependencies beyond Vite itself.
A reasonable rule: start with the hand-written configuration to understand what the output must look like, then adopt a plugin if its output matches and the time saved is real. Whichever you use, keep the output checks from step 4 in CI — they verify the result rather than the tool.
Cross-browser variation
- Chrome / Edge: the configuration above is Chrome-shaped: a module worker with shared chunks. Load
dist/chromeunpacked. - Firefox: point a second build at
dist/firefoxand generate a manifest withbackground.scripts. Firefox 121+ accepts module backgrounds; for older versions, add a third pass that bundles the worker as a single IIFE. - Safari: build a Chrome-shaped output and feed it to
xcrun safari-web-extension-converter. Keep Vite’s chunk names ASCII and short; the Xcode project embeds every file. - All three: set
build.targetto a baseline all your targets support —es2022is safe for current Chrome, Firefox and Safari — so Vite does not emit syntax an older engine rejects.
Verification
- Confirm the output has the files the manifest names, at those paths:
1node -e "
2const m = require('./dist/chrome/manifest.json');
3const fs = require('fs');
4const files = [m.background.service_worker, m.action?.default_popup, m.options_ui?.page,
5 ...m.content_scripts.flatMap((c) => c.js)].filter(Boolean);
6for (const f of files) if (!fs.existsSync('dist/chrome/' + f)) { console.error('missing', f); process.exit(1); }
7console.log('all manifest paths present');"
Execution context: your shell, after the build. This is the check that turns a mysterious “could not load” on install into a failed build.
- Confirm the content script has no
importstatements:grep -c "^import" dist/chrome/content/main.jsshould print0. - Load
dist/chromeunpacked and open every surface with DevTools; confirm no CSP errors. - Confirm no
.mapfile is referenced from the output:grep -rn "sourceMappingURL" dist/chrome/should print nothing withsourcemap: "hidden".
FAQ
Can I use Vite’s HMR in the popup?
Not with the dev server, because CSP blocks loading from localhost. Some plugins proxy it through the build; otherwise, watch mode plus automatic reload is close enough for most UI work.
Why is my content script importing a chunk?
Because it was built in the main pass, where Rollup split shared code into a chunk. Build content scripts in the separate lib/iife pass so they are self-contained.
Does public/manifest.json work instead of generating it?
It works for a single target, and Vite will copy it unchanged. For more than one browser, generate it — a static file cannot express per-target differences.
Related
- Webpack configuration for MV3 extensions — the same output with webpack.
- Using ES modules in an MV3 service worker — the worker format this emits.
- Hot reloading an extension during development — the development loop on top of watch mode.
- Build tooling and bundlers — the parent guide.