From wxt
Expert guidance for building browser extensions with WXT — covering project setup, file-based entrypoints (background, content scripts, popup, options, side panel), the unified browser API, auto-imports, manifest configuration, storage, content script UIs (integrated, shadow root, iframe), frontend frameworks (React, Vue, Svelte, Solid), multi-browser targeting (Chrome, Firefox, Safari, Edge), MV2/MV3 compatibility, and publishing. Use this skill whenever someone is building, debugging, or reviewing a WXT extension, asking how entrypoints work, trying to add a popup or content script, using `defineBackground`, `defineContentScript`, `createShadowRootUi`, `storage`, `wxt.config.ts`, or wondering why their extension code breaks at build time. Always invoke this skill for any question that mentions WXT, wxt.config.ts, defineBackground, defineContentScript, or refers to building a Chrome/Firefox extension using WXT, even if the question seems simple.
How this skill is triggered — by the user, by Claude, or both
Slash command
/wxt:wxtThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are an expert in WXT, the modern framework for building cross-browser web extensions. Your goal is to help users write correct, idiomatic WXT code that works across Chrome, Firefox, Edge, and Safari.
You are an expert in WXT, the modern framework for building cross-browser web extensions. Your goal is to help users write correct, idiomatic WXT code that works across Chrome, Firefox, Edge, and Safari.
WXT (inspired by Nuxt) is a build framework for web extensions that provides:
Bootstrap a new project:
pnpm dlx wxt@latest init # also works with npx/bunx
Templates: Vanilla, Vue, React, Svelte, Solid (all TypeScript by default).
Recommended package.json scripts:
{
"scripts": {
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"postinstall": "wxt prepare"
}
}
📂 project-root/
📁 .output/ ← build artifacts (gitignore this)
📁 .wxt/ ← generated TS config (gitignore this)
📁 assets/ ← CSS, images processed by Vite
📁 components/ ← auto-imported UI components
📁 composables/ ← auto-imported Vue composables
📁 entrypoints/ ← ⭐ all extension entrypoints go here
📁 hooks/ ← auto-imported React/Solid hooks
📁 modules/ ← local WXT modules
📁 public/ ← static files copied as-is (icons, etc.)
📁 utils/ ← auto-imported utilities
📄 wxt.config.ts ← main config
📄 web-ext.config.ts ← browser startup config
To use a src/ directory, set srcDir: 'src' in wxt.config.ts.
The entrypoints/ directory is the heart of WXT. File names determine entrypoint type. Each entrypoint is either a single file or a directory with an index file.
Critical rule: Never put code that uses browser APIs (browser.*, chrome.*, DOM APIs) outside the
main() function. WXT imports entrypoint files in a Node.js environment during build, so top-level
extension API calls will fail with errors like Browser.action.onClicked.addListener not implemented.
// entrypoints/background.ts
export default defineBackground(() => {
browser.action.onClicked.addListener(() => {
// ✅ browser.* is safe here inside main()
});
});
// With manifest options:
export default defineBackground({
persistent: false, // MV2 only
type: "module",
main() {
/* ... */
},
});
// entrypoints/content.ts OR entrypoints/my-feature.content.ts
export default defineContentScript({
matches: ["*://*.example.com/*"],
runAt: "document_idle", // 'document_start' | 'document_end' | 'document_idle'
world: "ISOLATED", // or 'MAIN' for main world access
cssInjectionMode: "manifest", // 'manifest' | 'manual' | 'ui'
main(ctx) {
// ctx tracks context invalidation
ctx.addEventListener(window, "resize", handler);
ctx.setInterval(() => {
/* ... */
}, 1000);
},
});
Multiple content scripts: name them foo.content.ts, bar.content.ts.
<!-- entrypoints/popup.html or entrypoints/popup/index.html -->
<!doctype html>
<html>
<head>
<title>Extension Popup</title>
<!-- For MV2 page_action instead of browser_action: -->
<!-- <meta name="manifest.type" content="page_action" /> -->
</head>
<body>
<script type="module" src="./main.ts"></script>
</body>
</html>
<!-- entrypoints/options.html -->
<!doctype html>
<html>
<head>
<meta name="manifest.open_in_tab" content="true" />
</head>
<body>
...
</body>
</html>
<!-- entrypoints/sidepanel.html -->
<!-- Chrome uses side_panel API; Firefox uses sidebar_action -->
<!-- WXT adds the sidepanel permission automatically -->
See references/entrypoints.md for full details on: Newtab, History, Bookmarks, Devtools, Sandbox, Unlisted Pages, Unlisted Scripts, Unlisted CSS.
WXT sets up auto-imports (like Nuxt) for:
defineBackground, defineContentScript, browser, storage, createShadowRootUi, etc.components/, composables/, hooks/, utils/You can use these without importing them. When auto-imports are disabled or you prefer explicit imports, use:
import { storage, createShadowRootUi } from "#imports";
import { browser } from "wxt/browser";
Run wxt prepare (or pnpm postinstall) to regenerate the .wxt/types/imports-module.d.ts type declarations after adding files.
WXT provides a unified browser variable that works across all browsers:
// Works in Chrome (uses chrome.*) and Firefox (uses browser.*)
browser.storage.local.set({ key: "value" });
browser.runtime.onMessage.addListener((msg, sender) => {
/* ... */
});
For feature detection (don't rely on types — they assume all APIs exist):
browser.runtime.onSuspend?.addListener(() => {
/* ... */
});
No manifest.json in source — WXT generates it from wxt.config.ts and entrypoint options:
// wxt.config.ts
import { defineConfig } from "wxt";
export default defineConfig({
manifest: {
name: "My Extension",
permissions: ["storage", "tabs"],
host_permissions: ["https://example.com/*"],
action: { default_title: "My Extension" },
},
});
Dynamic manifest based on target:
export default defineConfig({
manifest: ({ browser, manifestVersion }) => ({
permissions:
browser === "firefox"
? ["storage", "webRequest"]
: ["storage", "declarativeNetRequest"],
}),
});
MV2/MV3: Always write manifest in MV3 format — WXT auto-converts to MV2 when targeting Firefox/Safari. For example, define action (not browser_action); WXT handles the conversion.
Icons: place icon-16.png, icon-48.png, icon-128.png in public/ and WXT discovers them automatically.
WXT ships a built-in storage wrapper. All keys must be prefixed with the storage area:
// Quick access (needs 'storage' permission in manifest)
await storage.getItem<string>("local:username");
await storage.setItem("local:username", "alice");
await storage.removeItem("local:username");
// Reactive watcher
const unwatch = storage.watch<string>("local:username", (newVal, oldVal) => {});
unwatch(); // stop watching
Recommended: define typed storage items in utils/:
// utils/settings.ts
export const darkMode = storage.defineItem<boolean>("local:darkMode", {
fallback: false,
});
// Usage anywhere (auto-imported):
const isDark = await darkMode.getValue();
await darkMode.setValue(true);
darkMode.watch((val) => console.log("theme changed:", val));
Versioned storage for schema migrations:
export const prefs = storage.defineItem<PrefsV2>("local:prefs", {
version: 2,
fallback: defaultPrefs,
migrations: {
2: (oldPrefs: PrefsV1): PrefsV2 => ({ ...oldPrefs, newField: "default" }),
},
});
Add 'storage' to manifest.permissions in wxt.config.ts.
For rendering UI components onto a page, WXT provides three strategies. See references/content-scripts.md for full code examples with each framework.
| Method | Isolated CSS | Isolated Events | HMR | Use page context |
|---|---|---|---|---|
Integrated (createIntegratedUi) | ❌ | ❌ | ❌ | ✅ |
Shadow Root (createShadowRootUi) | ✅ | ✅ (opt-in) | ❌ | ✅ |
IFrame (createIframeUi) | ✅ | ✅ | ✅ | ❌ |
Shadow Root is the most commonly used — it isolates your extension's styles from the page:
// entrypoints/overlay.content/index.ts
import "./style.css";
export default defineContentScript({
matches: ["<all_urls>"],
cssInjectionMode: "ui", // required for shadow root
async main(ctx) {
const ui = await createShadowRootUi(ctx, {
name: "my-overlay",
position: "inline",
anchor: "body",
onMount(container) {
// mount your framework app here
},
});
ui.mount();
},
});
Install a module and add it to wxt.config.ts:
// React
import { defineConfig } from "wxt";
export default defineConfig({ modules: ["@wxt-dev/module-react"] });
// Vue
export default defineConfig({ modules: ["@wxt-dev/module-vue"] });
// Svelte
export default defineConfig({ modules: ["@wxt-dev/module-svelte"] });
// Solid
export default defineConfig({ modules: ["@wxt-dev/module-solid"] });
Each popup/options/sidepanel entrypoint needs its own app instance. Use a directory entrypoint with an index.html and framework-specific main.tsx/ts.
Router note: Web extension pages can't use path-based routing. Configure your router to use hash mode (e.g., createHashRouter for React Router, createWebHashHistory() for Vue Router).
Qwik in CSR-only mode: a render(root, <App/>) setup (no SSR) does not auto-inject qwikloader, and without it every onClick$ / onChange$
listener is silently dead — only useVisibleTask$ fires. Copy node_modules/@builder.io/qwik/dist/qwikloader.js into public/ and add
<script src="/qwikloader.js"></script> before the module script in each Qwik entrypoint's index.html. You can't import the loader
(Qwik's "sideEffects": false lets Vite tree-shake it) and you can't new Function(source)() it (MV3 CSP forbids unsafe-eval). The Qwik
skill's "CSR-only deployment" section has the full diagnosis.
MV3's default extension CSP is script-src 'self'; object-src 'self' — strictly no unsafe-eval. This silently breaks any code that does runtime code generation:
new Function(source)() — throws EvalError: ... unsafe-evaleval(...) — sameWhen you need a side-effect-only script that you'd normally import from node_modules, ship it as a real file under public/ and pull it in via a
non-module <script src=> from the entrypoint HTML. Vite copies public/ into the output unchanged. Don't rely on <script> tag injection from
JS either — MV3 also blocks eval-style script element creation for inline content.
Driving an unpacked extension over CDP has several non-obvious gotchas. The working pattern, which any reliable pnpm test:e2e-style harness converges on:
Recent Google Chrome stable releases (≳ v148) silently ignore --load-extension for unpacked extensions in headless mode — the flag is accepted,
the extension never loads, chrome://extensions shows an empty list, and your test sees ERR_BLOCKED_BY_CLIENT or
chrome-error://chromewebdata/. Playwright's bundled chromium loads them fine:
const chromePath =
process.env.E2E_CHROMIUM_PATH ||
"/home/<you>/.cache/ms-playwright/chromium-1200/chrome-linux64/chrome";
chromium.executablePath() is brittle here too — it hardcodes the path for whichever browser version Playwright thinks it should have,
which may not be the one actually downloaded. Allow override via env var.
Chrome derives an unpacked extension's ID deterministically from the absolute path of the load directory. No need to wait for the MV3 service worker to register as a CDP target (it's lazy and doesn't appear until something wakes it):
import crypto from "node:crypto";
const computeExtensionId = (absPath: string): string => {
const hex = crypto
.createHash("sha256")
.update(absPath)
.digest("hex")
.slice(0, 32);
const a = "a".charCodeAt(0);
return Array.from(hex, (c) =>
String.fromCharCode(a + parseInt(c, 16)),
).join("");
};
This is far more reliable than scraping the SW URL or the chrome://extensions DOM.
chromium.launchPersistentContext() is finicky with extensions + headless mode (the SW often fails to register and event subscriptions to it
never fire, even with --headless=new). Spawn Chrome directly and attach over CDP:
const proc = spawn(chromePath, [
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--no-first-run",
"--no-default-browser-check",
`--user-data-dir=${userDataDir}`,
`--remote-debugging-port=${port}`,
`--disable-extensions-except=${EXT_PATH}`,
`--load-extension=${EXT_PATH}`,
]);
// poll http://127.0.0.1:${port}/json/version until it answers, then:
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
const ctx = browser.contexts()[0]!;
Always pass --disable-extensions-except together with --load-extension. Without it, Chrome also loads its bundled extensions (Hangouts,
Cast, etc.) and they pollute ctx.serviceWorkers() and the target list, making "find our SW" heuristics unreliable.
chrome-extension:// URLs via raw CDP, not page.gotoAfter chromium.connectOverCDP(), calling page.goto("chrome-extension://<id>/popup.html") on an existing tab fails with
ERR_BLOCKED_BY_CLIENT — Playwright's CDP attach refuses the scheme. Spawn the popup as a brand-new CDP target via the JSON HTTP endpoint
instead, and attach via the page event:
const evt = ctx.waitForEvent("page", { timeout: 10_000 });
await fetch(`${cdpUrl}/json/new?${encodeURI(popupUrl)}`, { method: "PUT" });
const page = await evt;
The PUT verb is required — Chrome rejects GET on /json/new with "Using unsafe HTTP verb GET to invoke /json/new. This action supports only PUT verb."
When persistence is suspect (state appears to update but doesn't survive reload), read chrome.storage.local from inside the page to confirm
what was actually written, rather than just asserting the DOM:
const stored = await page.evaluate(
() =>
new Promise<Record<string, unknown>>((r) =>
chrome.storage.local.get(null, r),
),
);
An empty object here when you expected a value usually means the framework's change handler didn't fire — and in CSR-mode Qwik that's the qwikloader symptom described above.
wxt -b firefox # dev mode for Firefox
wxt build -b safari # build for Safari
wxt -b chrome --mv2 # Chrome MV2
Runtime browser detection:
if (import.meta.env.BROWSER === "firefox") {
/* ... */
}
if (import.meta.env.FIREFOX) {
/* shorthand */
}
if (import.meta.env.MANIFEST_VERSION === 2) {
/* ... */
}
Per-entrypoint filtering:
export default defineContentScript({
include: ["firefox"], // only built for Firefox
matches: ["*://*/*"],
main(ctx) {
/* ... */
},
});
Extension API calls at top level — the most common mistake:
// ❌ Breaks at build time — WXT imports this file in Node.js
browser.action.onClicked.addListener(() => {});
// ✅ Always wrap in the main() function
export default defineBackground(() => {
browser.action.onClicked.addListener(() => {});
});
Deeply nested entrypoints — WXT only discovers 0–1 levels deep:
entrypoints/
youtube/content/index.ts ❌ not discovered
youtube.content/index.ts ✅ correct
Related files inside entrypoints/ — put them in a directory entrypoint:
entrypoints/
popup.ts ❌ also don't put popup.css next to it
popup/ ✅ use a folder instead
index.html
main.ts
style.css
Read these when you need deeper details:
references/entrypoints.md — complete list of all entrypoint types with code templates (Newtab, History, Bookmarks, Devtools, Sandbox, Unlisted Pages/Scripts/CSS, Popup, Options, Side Panel)references/content-scripts.md — content script context API, CSS injection, and all three UI strategies (Integrated, Shadow Root, IFrame) with per-framework code examplesreferences/storage.md — full storage API: bulk operations, metadata, versioned migrations, defineItem patternsCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub 46ki75/claude-plugins --plugin wxt