Scaffolding an Extension with WXT

Start an MV3 extension with WXT: file-based entrypoints, defineBackground and defineContentScript, generated manifests per browser, dev mode with reload, auto-imports, and zips for every store.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

Setting up an extension build by hand means writing a manifest, wiring a bundler to produce a service worker, content scripts and several HTML pages, adding reload during development, and generating different manifests for Chrome and Firefox. WXT is a framework that does all of that from a directory of files: each file under entrypoints/ becomes a background script, content script or page, and the manifest is generated from them. It is built on Vite and framework-agnostic. This guide walks through scaffolding a project, the conventions that matter, and the points where you still need to understand what WXT generates. It belongs to build tooling and bundlers.

What WXT generates for you

A WXT project is a set of entrypoints — files named by convention in entrypoints/ — plus a wxt.config.ts. At build time WXT reads each entrypoint, bundles it with Vite, and generates manifest.json from the entrypoints’ declarations: background.ts becomes background.service_worker (or background.scripts for Firefox), content.ts with a matches option becomes a content_scripts entry, popup/index.html becomes action.default_popup, options/index.html becomes options_ui. Running the build with a browser flag produces a manifest and bundle tailored to that browser. In development, WXT launches a browser with the extension loaded and reloads it on changes. The result is still an ordinary MV3 extension; WXT disappears at build time apart from a small runtime helper.

From entrypoints to per-browser buildsFiles in entrypoints are discovered by name, bundled with Vite, and combined with wxt.config into a generated manifest; build targets produce separate outputs and zips for Chrome, Firefox and other browsers.entrypoints/background, content, popupwxt.config.tsmanifest extrasVite buildper entrypointper target browser.output/chrome-mv3service_worker.output/firefox-mv3background.scriptswxt zipstore uploads
You write entrypoints; WXT writes the manifest and the per-browser packaging.

Step-by-step: a working WXT project

1. Initialise the project

1npx wxt@latest init readable
2cd readable
3npm install
4npm run dev                      # launches Chrome with the extension loaded

Execution context: a terminal. The initialiser asks for a template (vanilla, React, Vue, Svelte, Solid) and a package manager. npm run dev builds into .output/chrome-mv3-dev, opens a separate Chrome profile with the extension installed, and rebuilds and reloads on file changes. npm run dev:firefox does the same for Firefox.

2. Write the background entrypoint

 1// entrypoints/background.ts
 2export default defineBackground({
 3  type: "module",
 4  main() {
 5    browser.runtime.onInstalled.addListener(({ reason }) => {
 6      if (reason === "install") browser.tabs.create({ url: browser.runtime.getURL("/welcome.html") });
 7    });
 8    browser.runtime.onMessage.addListener(handleMessage);
 9  },
10});

Execution context: the generated service worker (Chrome) or event page (Firefox). WXT wraps main so it runs synchronously at the top level of the worker, which preserves MV3’s rule that listeners register in the first pass — do not await before registering listeners inside main. defineBackground and browser are auto-imported; WXT provides a browser object that works in every target.

Entrypoint files and what they generateCommon WXT entrypoint file names and the manifest key or packaged file each produces.EntrypointGeneratesNotesbackground.tsbackground.service_worker / scriptsPer targetcontent.ts / *.content.tscontent_scripts entrymatches in codepopup/index.htmlaction.default_popupMeta tags for titleoptions/index.htmloptions_uiopen_in_tab optionsidepanel/index.htmlside_panel / sidebar_actionPer browser*.html (other)Unlisted pagegetURL to open
The file name is the declaration — no manual manifest edits for standard surfaces.

3. Write a content script with its matches

 1// entrypoints/article.content.ts
 2export default defineContentScript({
 3  matches: ["https://*.example.com/*"],
 4  runAt: "document_idle",
 5  cssInjectionMode: "ui",
 6  async main(ctx) {
 7    const ui = await createShadowRootUi(ctx, {
 8      name: "readable-fab",
 9      position: "overlay",
10      onMount(container) { container.append(renderButton()); },
11    });
12    ui.mount();
13    ctx.onInvalidated(() => ui.remove());           // cleanup when the extension updates
14  },
15});

Execution context: a content script in the page’s isolated world. matches in code becomes the manifest’s content_scripts.matches, keeping the two together. The ctx object tracks the extension context: ctx.onInvalidated runs when the extension is updated or reloaded, solving the orphaned-UI problem described in removing injected UI cleanly. createShadowRootUi mounts UI inside a shadow root with the entrypoint’s CSS.

4. Add manifest fields WXT cannot infer

 1// wxt.config.ts
 2import { defineConfig } from "wxt";
 3
 4export default defineConfig({
 5  manifest: ({ browser }) => ({
 6    name: "__MSG_appName__",
 7    default_locale: "en",
 8    permissions: ["storage", "activeTab", "scripting", "alarms"],
 9    host_permissions: ["https://api.readable.example/*"],
10    ...(browser === "firefox" && {
11      browser_specific_settings: { gecko: { id: "readable@acme.example", strict_min_version: "121.0" } },
12    }),
13  }),
14});

Execution context: build configuration. Permissions, host permissions, locales, icons and browser-specific settings come from here; entrypoint-derived keys are merged in. A function form receives the target browser, which is the clean way to add Firefox’s gecko id or drop Chrome-only permissions per target.

A WXT development loopThe developer saves a content script; WXT rebuilds that entrypoint with Vite, regenerates the manifest if needed, reloads the extension in the dev browser, and reloads matching tabs.EditorWXT dev serverDev browsersave article.content.tsrebuild entrypointreload extensionreload matching…
Edit, save, see — without visiting chrome://extensions.

5. Build and zip for each store

1npm run build                    # .output/chrome-mv3
2npm run build:firefox            # .output/firefox-mv3
3npx wxt zip                      # .output/readable-1.4.0-chrome.zip
4npx wxt zip -b firefox           # plus a sources zip for AMO review

Execution context: a terminal or CI. wxt zip for Firefox also produces a source archive, which AMO requires when the uploaded code is bundled or minified. Check the generated .output/*/manifest.json into a snapshot test so unintended manifest changes show up in review — WXT makes manifest changes easy, which makes accidental ones easy too. See snapshot testing the generated manifest.

6. Use storage and messaging helpers deliberately

1// utils/storage.ts
2export const theme = storage.defineItem<"light" | "dark">("sync:theme", { fallback: "light" });
3
4// anywhere
5await theme.setValue("dark");
6theme.watch((next) => applyTheme(next));

Execution context: any extension context. WXT’s storage wrapper adds typed items, defaults, versioned migrations and watchers on top of browser.storage. It is convenient, and it is a layer you depend on; the underlying data is ordinary chrome.storage, so you can read it without WXT if you migrate away.

7. Know what to inspect when something breaks

When a behaviour surprises you, read the output rather than the framework’s documentation: .output/chrome-mv3/manifest.json shows exactly what was declared, and the bundled background.js shows where your code ended up. Most “WXT bugs” turn out to be MV3 rules — a listener registered after an await, a content script whose matches did not include a subdomain — that the generated files make visible.

Common mistakes

  • Awaiting before registering listeners in main. The worker misses the event that woke it.
  • Editing the generated manifest. It is overwritten on the next build; configure in wxt.config.ts.
  • Assuming dev mode equals production. Dev builds include reload helpers; test the production build.
  • Forgetting the Firefox gecko id. Add it per target in the config.
  • Treating browser as a polyfill for missing APIs. It normalises namespaces, not capabilities.

Cross-browser variation

  • Chrome / Edge: chrome-mv3 target with service_worker background and side_panel.
  • Firefox: firefox-mv3 target with background.scripts and sidebar_action; wxt zip -b firefox produces the sources archive AMO asks for.
  • Safari: WXT builds a Safari-compatible web extension folder; wrap it with Xcode’s converter as described in porting a Chrome extension to Safari with Xcode.

Verification

  1. Run npm run dev and confirm the dev browser opens with the extension loaded and the welcome page appears on first install.
  2. Edit the content script and confirm the change appears after an automatic reload.
  3. Build for Chrome and Firefox and diff the two manifests: background form and browser-specific keys differ as intended.
  4. Load the production zip unpacked in a clean profile and exercise every entrypoint.

FAQ

Can I migrate an existing extension to WXT?

Yes: move each script into entrypoints/ with the matching name, move manifest fields into wxt.config.ts, and compare the generated manifest with your old one.

Does WXT add runtime overhead?

A small helper for browser and context tracking. The rest is your code, bundled by Vite.

Is WXT required for cross-browser builds?

No — the same can be done by hand, as in generating a manifest per browser target.

Does WXT support Manifest V2 for older Firefox users?

WXT can target MV2 per browser, which some teams use for Firefox while it still accepts MV2. Prefer MV3 everywhere unless a specific dependency forces otherwise — two background models double the testing.

Can I keep using plain chrome.* calls?

Yes. The browser global is a convenience; chrome.* works in Chromium targets, and browser.* in Firefox. Using WXT’s browser everywhere keeps code portable without a polyfill.

Where do icons and locales go?

Static assets go in public/, which WXT copies verbatim into the output; _locales and icon PNGs belong there, referenced from wxt.config.ts.

Other MV3 Architecture & Extension Lifecycle Resources