brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
wxtWXT is a Vite-based framework that builds browser extensions for Chrome, Firefox, Safari, and Edge from one codebase. Use when someone asks to "build a browser extension", "Chrome extension with React", "WXT framework", "cross- browser extension", "manifest v3 extension", "build Firefox extension", or "browser extension with TypeScript". Covers content scri…
Independent GitHub snapshot · Automated classification · Original documentation governs
WXT is a Vite-based framework for building browser extensions — its own tagline is "like Nuxt, but for web extensions." File-based entrypoints, hot reload, TypeScript-first, auto-imports. One codebase produces a separate build per browser: Manifest V3 for Chrome and Edge, Manifest V2 by default for Firefox and Safari (pass --mv3 to override). There is no manifest.json in the source tree — WXT generates it from wxt.config.ts and the entrypoint files.
WXT is still pre-1.0, so a change in the second digit (0.20 → 0.21) is a breaking release. This skill matches 0.21, which needs Node.js 22+, and makes vite a required peer dependency and web-ext an optional one (without web-ext the dev command no longer opens a browser).
npx wxt@latest init code-explainer -t react --pm npm # templates: vanilla, vue, react, svelte, solid
cd code-explainer
npm install # postinstall runs `wxt prepare`, which generates .wxt/ (types, tsconfig)
npm run dev # Opens Chrome with the extension loaded and hot reload
Without -t and --pm the init command asks for the template and package manager. To add WXT to an existing project: npm i -D wxt vite typescript, plus web-ext if the dev command should open a browser.
code-explainer/
├── entrypoints/
│ ├── popup/ # Popup UI (click extension icon)
│ │ ├── index.html
│ │ ├── main.tsx
│ │ └── App.tsx
│ ├── content.ts # Content script (runs on web pages)
│ └── background.ts # Service worker (background logic)
├── utils/ # Auto-imported helpers (also components/, hooks/, composables/)
├── public/
│ └── icon/ # 16.png, 32.png, 48.png, 96.png, 128.png — found automatically
├── .env # WXT_* and VITE_* variables, exposed on import.meta.env
├── wxt.config.ts
└── package.json
More content scripts go in files named {name}.content.ts. Other recognised entrypoint names include options, sidepanel, newtab and devtools (each a folder with an index.html).
WXT does not add permissions for you. Every API used below has to be declared, or it is undefined at runtime (storage throws):
// wxt.config.ts
import { defineConfig } from "wxt";
export default defineConfig({
modules: ["@wxt-dev/module-react"],
manifest: {
name: "Code Explainer",
permissions: ["storage", "alarms"],
host_permissions: ["https://api.openai.com/*"],
},
});
Write manifest keys in MV3 form (action, host_permissions); WXT converts them for MV2 builds. The manifest name and version default to the values in package.json, which also name the zip files — the template ships as wxt-react-starter 0.0.0, so edit both.
// utils/storage.ts — typed storage items, auto-imported everywhere
export const explainCount = storage.defineItem<number>("local:explainCount", { fallback: 0 });
// entrypoints/content.ts — Runs on matched web pages; createShadowRootUi isolates the button's styles.
// DOM and extension API calls must stay inside main(): WXT imports this file in Node at build time.
export default defineContentScript({
matches: ["https://github.com/*"],
cssInjectionMode: "ui",
async main(ctx) {
const ui = await createShadowRootUi(ctx, {
name: "code-explainer",
position: "inline",
anchor: "body",
onMount(container) {
const btn = document.createElement("button");
btn.textContent = "Explain selection";
btn.style.cssText = "position:fixed;right:16px;bottom:16px;z-index:9999";
btn.onclick = async () => {
const code = window.getSelection()?.toString().slice(0, 5000);
if (!code) return;
// Send to background for the API call
const reply = await browser.runtime.sendMessage({ type: "EXPLAIN", code });
alert(reply.error ?? reply.summary);
};
container.append(btn);
},
});
ui.mount();
},
});
// entrypoints/background.ts — Service worker: API calls, alarms, message routing. No DOM access.
// The main function cannot be async.
export default defineBackground(() => {
// Since WXT 0.20 `browser` is the native API, not webextension-polyfill:
// answer with sendResponse and return true instead of returning a promise.
browser.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type !== "EXPLAIN") return;
explain(msg.code)
.then((summary) => sendResponse({ summary }))
.catch((err) => sendResponse({ error: String(err) }));
return true; // keep the channel open until sendResponse runs
});
// Periodic tasks with alarms
browser.alarms.create("refresh-badge", { periodInMinutes: 30 });
browser.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== "refresh-badge") return;
const count = await explainCount.getValue();
// MV2 builds (the Firefox default) only have browser.browserAction
await (browser.action ?? browser.browserAction).setBadgeText({ text: count ? String(count) : "" });
});
});
async function explain(code: string): Promise<string> {
const apiKey = await storage.getItem<string>("local:apiKey");
if (!apiKey) throw new Error("Save an API key in the popup first");
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: import.meta.env.WXT_OPENAI_MODEL, // set in .env
messages: [{ role: "user", content: `Explain this code:\n${code}` }],
}),
});
if (!response.ok) throw new Error(`OpenAI API returned ${response.status}`);
const data = await response.json();
await explainCount.setValue((await explainCount.getValue()) + 1);
return data.choices[0].message.content;
}
// entrypoints/popup/App.tsx — Extension popup with React
import { useState, useEffect } from "react";
export default function App() {
const [apiKey, setApiKey] = useState("");
const [saved, setSaved] = useState(false);
useEffect(() => {
storage.getItem<string>("local:apiKey").then((key) => key && setApiKey(key));
}, []);
const save = async () => {
await storage.setItem("local:apiKey", apiKey);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
return (
<div style={{ width: 300, padding: 16 }}>
<h2>Code Explainer</h2>
<input type="password" value={apiKey} placeholder="OpenAI API Key"
onChange={(e) => setApiKey(e.target.value)} style={{ width: "100%" }} />
<button onClick={save} style={{ marginTop: 8 }}>{saved ? "Saved" : "Save Key"}</button>
</div>
);
}
npm run dev # Chrome with hot reload
npm run dev:firefox # Firefox
# Production builds, one output directory per target
npm run build # wxt build → .output/chrome-mv3/
npm run build:firefox # wxt build -b firefox → .output/firefox-mv2/
npx wxt build -b firefox --mv3 # → .output/firefox-mv3/
npx wxt build -b edge # or -b safari
# Zip for store submission
npm run zip # .output/code-explainer-1.0.0-chrome.zip
npm run zip:firefox # ...-firefox.zip plus ...-sources.zip, which Firefox review requires
Branch on the target with import.meta.env.BROWSER, import.meta.env.FIREFOX or import.meta.env.MANIFEST_VERSION; limit an entrypoint to some browsers with include: ["firefox"] or exclude: ["chrome"].
npx wxt submit init # asks for store credentials, writes .env.submit
npx wxt submit --dry-run \
--chrome-zip .output/code-explainer-1.0.0-chrome.zip \
--firefox-zip .output/code-explainer-1.0.0-firefox.zip \
--firefox-sources-zip .output/code-explainer-1.0.0-sources.zip
Drop --dry-run to submit. The first listing in each store has to be created by hand. Edge accepts the Chrome zip via --edge-zip. Safari is not automated: build with -b safari and wrap the output with Xcode's safari-web-extension-packager.
User prompt: "Build a Chrome extension that blocks Reddit and YouTube while I'm in a focus session."
Add a storage item and a second content script; the popup starts a session with focusUntil.setValue(Date.now() + 25 * 60_000).
// utils/storage.ts
export const focusUntil = storage.defineItem<number>("local:focusUntil", { fallback: 0 });
// entrypoints/blocker.content.ts
export default defineContentScript({
matches: ["*://*.reddit.com/*", "*://*.youtube.com/*"],
runAt: "document_start",
async main() {
const until = await focusUntil.getValue();
if (Date.now() >= until) return;
window.stop();
const time = new Date(until).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
document.documentElement.innerHTML = `<body><h1>Focus time. Back at ${time}.</h1></body>`;
},
});
npm run build picks the file up by name and adds it to the generated manifest:
"content_scripts": [
{ "matches": ["*://*.reddit.com/*", "*://*.youtube.com/*"], "run_at": "document_start", "js": ["content-scripts/blocker.js"] }
]
User prompt: "My WXT extension works in Chrome. Package it for the Firefox add-on store too."
npx wxt zip -b firefox
✔ Zipped extension in 44 ms
├─ .output/code-explainer-1.0.0-firefox.zip 95.69 kB
└─ .output/code-explainer-1.0.0-sources.zip 69.52 kB
The command also lists every file that went into the sources zip (hidden files such as .env are left out). The Firefox build is MV2: host_permissions are merged into permissions, action becomes browser_action, and the background runs as a script instead of a service worker. A new listing must first declare browser_specific_settings.gecko.data_collection_permissions in manifest (the build warns until it does; an explicit gecko.id is recommended). Upload both zips; the reviewers rebuild the extension from the sources zip, so check that npm i && npm run zip:firefox works inside it.
browser.* API — a plain alias for the browser's own browser/chrome global with @types/chrome types; the polyfill was removed in 0.20, so APIs a browser lacks are undefined (feature-detect with ?.)onMessage — use sendResponse plus return true, or a messaging library such as @webext-core/messaging; Chrome only began accepting returned promises in version 148storage, alarms, host_permissions and the rest go in manifest in wxt.config.tsstorage helper — keys carry their area prefix (local:, session:, sync:, managed:); prefer storage.defineItem for typed values with a fallback#imports (wxt/storage, wxt/client and wxt/sandbox no longer exist)npm run dev reloads content scripts and popup on savewxt:locationchange with ctx.addEventListener.env or the source is shipped inside the extension and readable by every user; take API keys from the user at runtime or proxy calls through your own server--ignore-scripts, apply the steps in the upgrade guide, then run wxt preparewxt submit init and choose v2 (service-account auth)