In a previous article, I built a bookmarklet to clip product data from e-commerce pages using Shadow DOM and structured data. It worked — until it didn't.
The bookmarklet could detect products and display them in a floating UI, but it had real limitations: no persistent state between pages, no way to batch products from multiple sites, and no communication channel back to my SaaS app. Every time the user navigated, everything was gone.
I needed something that could live across pages, store data, and talk to my backend. That meant a browser extension.
Here's how I built a cross-browser extension (Chrome + Firefox) using WXT, TypeScript, and React — from architecture to publishing.
Why WXT?
If you've ever built a browser extension from scratch, you know the pain: manually wiring up manifest.json, handling Chrome vs Firefox API differences, reloading the extension after every change, figuring out which context runs where.
WXT is a framework that solves all of this. Think of it as Vite for browser extensions:
- One codebase, multiple browsers. WXT outputs separate builds for Chrome and Firefox from the same source.
- Hot reload. Change a content script, save, see it live. Welcome to the future. No more "reload extension → refresh page → wonder why nothing changed → realize you forgot to reload → reload again → pray".
-
File-based entrypoints. Drop a file in
entrypoints/, WXT wires it into the manifest automatically. -
browserpolyfill. Chrome useschrome.*, Firefox usesbrowser.*with Promises. WXT's polyfill unifies them — you writebrowser.runtime.sendMessage()and it works everywhere.
Getting started:
npx wxt@latest init my-extension
The three contexts you need to understand
Browser extensions aren't a single program. They're three separate execution contexts that communicate via message passing. Understanding this is the key to building anything non-trivial.
┌──────────────┐ ┌────────────────┐ ┌──────────────────┐
│ Popup │ runtime.send │ Background │ scripting. │ Content Script │
│ (React) │ ──────────────► │ (Service Worker)│ executeScript() │ (injected into │
│ │ ◄────────────── │ │ ──────────────► │ any web page) │
│ Cart UI │ response │ Message hub │ │ │
│ Import flow │ │ Storage │ │ Sidebar UI │
│ Auth check │ │ API calls │ │ Page highlights │
└──────────────┘ │ Auth/tokens │ │ Product scan │
└────────────────┘ └──────────────────┘
Background (Service Worker) — The brain. It's the only context that persists (sort of — MV3 service workers can be suspended). It handles storage, API calls, authentication, and orchestrates everything. No DOM access.
Content Script — Injected into web pages. It can read and modify the page's DOM, but it's isolated from the page's JavaScript. It talks to the background via browser.runtime.sendMessage().
Popup — A small standalone UI (ours is React + Tailwind). It opens when the user clicks the extension icon. Same messaging as content scripts. It dies when closed — no persistent state here, everything lives in the background's storage.
Project structure
WXT uses a file-based convention. Here's the layout:
src/
├── entrypoints/
│ ├── background.ts # Service worker
│ ├── content.ts # Injected into scanned pages
│ ├── app-bridge.content.ts # Lightweight bridge for our app
│ └── popup/
│ ├── index.html
│ ├── main.tsx
│ └── App.tsx
├── components/popup/ # React components for the popup
├── lib/
│ ├── api/client.ts # API wrapper
│ ├── detection/ # Product detection engine
│ ├── highlights/manager.ts # Visual feedback on page
│ ├── storage/ # Cart + session persistence
│ └── messaging/types.ts # Typed message definitions
└── hooks/useCart.ts
Every file in entrypoints/ becomes an extension entrypoint. WXT reads the export default to configure it — no manual manifest editing.
Manifest and permissions
// wxt.config.ts
export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifest: {
name: 'My Extension',
permissions: ['activeTab', 'storage', 'scripting'],
host_permissions: [
'https://app.my-saas.com/*',
],
},
});
Three permissions, all justified:
-
activeTab— access the current tab when the user clicks "Scan" -
storage— persist cart items and session across pages -
scripting— inject the content script dynamically (not on every page, only on demand)
No <all_urls>, no cookies, no broad host permissions. Chrome Web Store reviewers care about this, and so should you.
Type-safe message passing
Message passing between contexts is stringly-typed by default. A typo in your action name and you get silent failures. We fix this with a union type:
// lib/messaging/types.ts
export type ExtensionMessage =
| { type: 'ADD_TO_CART'; products: CartProduct[] }
| { type: 'REMOVE_FROM_CART'; urls: string[] }
| { type: 'GET_CART' }
| { type: 'CLEAR_CART' }
| { type: 'CLEAR_SITE'; siteOrigin: string }
| { type: 'IMPORT_CART'; siteUrl?: string; siteId?: number }
| { type: 'SCAN_PAGE' }
| { type: 'CHECK_AUTH' }
| { type: 'SAVE_SESSION'; session: AuthSession }
| { type: 'GET_SITES' }
| { type: 'LOGOUT' };
Every message has a discriminated type field. TypeScript narrows the payload automatically:
// entrypoints/background.ts
browser.runtime.onMessage.addListener((message: ExtensionMessage, sender, sendResponse) => {
switch (message.type) {
case 'ADD_TO_CART':
// message.products is typed as CartProduct[]
handleAddToCart(message.products).then(sendResponse);
return true; // keep channel open for async response
case 'IMPORT_CART':
// message.siteUrl is typed as string | undefined
handleImport(message.siteUrl, message.siteId).then(sendResponse);
return true;
case 'GET_CART':
getCartItems().then(sendResponse);
return true;
}
});
The return true is a gotcha that will cost you hours if you don't know about it. Ask me how I know. By default, the message channel closes synchronously. Returning true tells the browser "I'll respond asynchronously" — without it, your sendResponse calls silently fail.
Dynamic content script injection
Instead of injecting our content script on every web page (which would require <all_urls> permission and slow down every page load), we inject it on demand when the user clicks "Scan":
// entrypoints/background.ts
case 'SCAN_PAGE':
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id) {
await browser.scripting.executeScript({
target: { tabId: tab.id },
files: ['/content-scripts/content.js'],
});
}
sendResponse({ success: true });
return true;
The content script protects against double-injection with a DOM marker:
// entrypoints/content.ts
export default defineContentScript({
matches: ['<all_urls>'], // required by WXT, but never auto-injected — we only use executeScript()
main() {
if (document.querySelector('[data-my-extension-host]')) return;
// ... initialize sidebar, run detection, set up highlights
},
});
The matches: ['<all_urls>'] might look scary, but it's only there because WXT's type system requires it. Since we exclusively inject via scripting.executeScript(), the content script never runs automatically. This gives us the best of both worlds: the extension only runs on pages the user explicitly scans, but works on any site without listing specific domains.
The content script: sidebar + highlights
When the content script runs, it does three things:
- Scans the page for products (using structured data, DOM heuristics, and URL patterns)
- Renders a sidebar in a Shadow DOM container (same isolation technique as the bookmarklet)
- Highlights detected products on the page with visual overlays
The sidebar is built with vanilla DOM manipulation inside a Shadow DOM — no framework overhead in the content script. The highlight system uses CSS injected via a <style> tag with attribute selectors:
// lib/highlights/manager.ts
element.setAttribute('data-ext', 'selected'); // green border + checkmark
element.setAttribute('data-ext', 'deselected'); // dashed outline
element.setAttribute('data-ext', 'hover'); // neon glow + "SIGNAL DETECTED" badge
Each state has distinct visual feedback — selected products get a green checkmark overlay, hovered products get a glowing badge. It makes the scanning experience feel alive.
Handling SPAs and infinite scroll
Modern e-commerce sites don't do full page reloads. Products load via infinite scroll or client-side navigation. A static scan would miss everything loaded after the initial page.
We use a MutationObserver with debouncing:
const observer = new MutationObserver(() => {
clearTimeout(rescanTimer);
rescanTimer = setTimeout(() => rescanAndMerge(items), 800);
});
observer.observe(document.body, { childList: true, subtree: true });
The 800ms debounce is important — without it, a single infinite scroll event could trigger dozens of rescans as DOM nodes are added one by one.
The merge logic is path-aware: if the URL pathname changed (SPA navigation), new products are prepended to the list. If it's the same path (infinite scroll), they're appended. This keeps the sidebar order intuitive.
Authentication: cookie-based with token refresh
The popup authenticates against our Rails backend using the user's existing session:
// App.tsx — on popup open
const response = await fetch(`${HOST}/api/extension/session`, {
credentials: 'include', // sends Devise session cookies
});
const { token, user } = await response.json();
The credentials: 'include' is what makes this work — the popup runs on the extension's origin, but host_permissions for our domain allows it to send cookies cross-origin. The backend validates the Devise session and returns a short-lived Bearer token.
That token is stored in the background and used for all subsequent API calls. When it expires (401), we automatically refresh:
// lib/api/client.ts
async function importProducts(urls: string[], token: string): Promise<ImportResult> {
const response = await fetch(`${HOST}/api/import`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ urls }),
});
if (response.status === 401) {
throw new AuthExpiredError();
}
return response.json();
}
The background catches AuthExpiredError, refreshes the token via the cookie-based endpoint, and retries once. If that fails too, the user sees a "Session expired" screen.
The popup: React + Tailwind in 380x600px
The popup is a tiny React app with a focused UX: view your cart, pick a target project, import.
Products are grouped by site origin — you might scan three different competitor sites in a session, and each group is collapsible with its own "clear" action.
The import button shows real-time progress and results:
// components/popup/ImportButton.tsx
const handleImport = async () => {
setStatus('importing');
setProgress(15);
const result = await browser.runtime.sendMessage({
type: 'IMPORT_CART',
siteId: selectedSite?.id,
});
setProgress(100);
setResult(result); // { created: 5, skipped: 2, errors: 0 }
};
Detecting the extension from your web app
A lightweight content script runs on our SaaS domain to let the web app know the extension is installed:
// entrypoints/app-bridge.content.ts
export default defineContentScript({
matches: ['https://app.my-saas.com/*'],
main() {
window.addEventListener('message', (event) => {
if (event.data?.type === 'CHECK_EXTENSION') {
window.postMessage({
type: 'EXTENSION_INSTALLED',
version: browser.runtime.getManifest().version,
}, '*');
}
});
},
});
On the web app side:
window.postMessage({ type: 'CHECK_EXTENSION' }, '*');
window.addEventListener('message', (e) => {
if (e.data.type === 'EXTENSION_INSTALLED') {
showExtensionFeatures(e.data.version);
}
});
This is the simplest form of extension detection — no external messaging API, no externally_connectable manifest key. The content script acts as a bridge between two isolated worlds.
Building and publishing
WXT makes cross-browser builds a one-liner:
wxt build # Chrome (default)
wxt build --browser firefox # Firefox
Firefox AMO: zip the output, upload, done. Firefox reviewed and approved ours in under an hour.
Chrome Web Store: same zip, but after you've justified every single permission, uploaded screenshots, provided a detailed description of why your extension exists, submitted your blood test results, and filed your tax return — they'll graciously review your extension within 24 hours.
The only Firefox-specific configuration is the browser_specific_settings.gecko block in the manifest (the extension ID and minimum Firefox version). Everything else is shared.
What I learned
Start with the messaging architecture. Define your message types first. Everything flows from there — what the background handles, what the popup shows, what the content script sends. Type them from day one.
Dynamic injection > static injection. Don't inject content scripts on every page unless you genuinely need to. scripting.executeScript() with activeTab is cleaner, faster, and requires fewer permissions.
Shadow DOM is still your friend. The sidebar technique from the bookmarklet carried over perfectly. Shadow DOM isolation means your extension's UI won't break on sites with aggressive global CSS.
MutationObserver needs debouncing. Without it, infinite scroll pages will obliterate your performance. 800ms worked well for us, but tune it to your use case.
WXT is the right call. I can't imagine going back to raw manifest wiring. The hot reload alone saves hours per week during development.
Now go build something, break things, and enjoy it.
This is the second article in a series about building web tools for e-commerce product tracking. The first one covers the bookmarklet that started it all.



Top comments (0)