WXT Framework Patterns
Comprehensive guide for building cross-browser extensions with WXT, including security hardening, Firefox/Safari specifics, and production patterns.
Overview
WXT is the leading framework for browser extension development, offering:
-
Cross-browser support: Chrome, Firefox, Edge, Safari
-
Manifest agnostic: MV2 and MV3 from single codebase
-
File-based entrypoints: Auto-generated manifest
-
Vite-powered: Fast HMR for all script types
-
Framework agnostic: React, Vue, Svelte, Solid, vanilla
This skill covers:
-
Project structure and entrypoint patterns
-
Configuration and manifest generation
-
Security hardening rules (49 rules)
-
Firefox-specific patterns
-
Safari-specific patterns
-
Testing and debugging
This skill does NOT cover:
-
General JavaScript/TypeScript patterns
-
Specific UI framework implementations
-
Store submission process (see store-submission skill)
Quick Reference
CLI Commands
Command Purpose
wxt
Start dev mode with HMR
wxt build
Production build
wxt build -b firefox
Firefox-specific build
wxt zip
Package for distribution
wxt prepare
Generate TypeScript types
wxt clean
Clean output directories
wxt submit
Publish to stores
Entrypoint Types
Type File Manifest Key
Background entrypoints/background.ts
background.service_worker
Content Script entrypoints/content.ts
content_scripts
Popup entrypoints/popup/
action.default_popup
Options entrypoints/options/
options_page
Side Panel entrypoints/sidepanel/
side_panel
Unlisted entrypoints/*.ts
Not in manifest
Project Structure
my-extension/ ├── entrypoints/ │ ├── background.ts # Service worker │ ├── content.ts # Content script │ ├── content/ # Multi-file content script │ │ ├── index.ts │ │ └── styles.css │ ├── popup/ │ │ ├── index.html │ │ ├── main.ts │ │ └── App.vue │ ├── options/ │ │ └── index.html │ └── sidepanel/ │ └── index.html ├── public/ │ └── icon/ │ ├── 16.png │ ├── 32.png │ ├── 48.png │ └── 128.png ├── utils/ # Shared utilities ├── wxt.config.ts # WXT configuration ├── tsconfig.json └── package.json
Entrypoint Patterns
Background Script (Service Worker)
// entrypoints/background.ts export default defineBackground(() => { console.log('Extension loaded', { id: browser.runtime.id });
// Handle messages from content scripts browser.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'getData') { handleGetData(message.payload).then(sendResponse); return true; // Keep channel open for async response } });
// Use alarms for recurring tasks (MV3 service worker friendly) browser.alarms.create('sync', { periodInMinutes: 5 }); browser.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'sync') { performSync(); } }); });
Content Script
// entrypoints/content.ts export default defineContentScript({ matches: ['://.example.com/*'], runAt: 'document_idle',
main(ctx) { console.log('Content script loaded');
// Use context for lifecycle management
ctx.onInvalidated(() => {
console.log('Extension updated/disabled');
cleanup();
});
// Create isolated UI
const ui = createShadowRootUi(ctx, {
name: 'my-extension-ui',
position: 'inline',
anchor: '#target-element',
onMount(container) {
// Mount your UI framework here
return mount(App, { target: container });
},
onRemove(app) {
app.$destroy();
},
});
ui.mount();
}, });
Content Script with Main World Access
// entrypoints/content.ts export default defineContentScript({ matches: ['://.example.com/*'], world: 'MAIN', // Access page's JavaScript context
main() { // Can access page's window object window.myExtensionApi = { getData: () => { /* ... */ } }; }, });
Popup with Framework
<!-- entrypoints/popup/index.html --> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> </head> <body> <div id="app"></div> <script type="module" src="./main.ts"></script> </body> </html>
// entrypoints/popup/main.ts import { createApp } from 'vue'; import App from './App.vue'; import './style.css';
createApp(App).mount('#app');
Configuration
Basic Configuration
// wxt.config.ts import { defineConfig } from 'wxt';
export default defineConfig({ srcDir: 'src', entrypointsDir: 'src/entrypoints', outDir: 'dist',
manifest: { name: 'My Extension', description: 'Extension description', version: '1.0.0', permissions: ['storage', 'activeTab'], host_permissions: ['://.example.com/*'], }, });
Cross-Browser Configuration
// wxt.config.ts import { defineConfig } from 'wxt';
export default defineConfig({ manifest: ({ browser }) => ({ name: 'My Extension', description: 'Cross-browser extension',
// Browser-specific settings
...(browser === 'firefox' && {
browser_specific_settings: {
gecko: {
id: 'my-extension@example.com',
strict_min_version: '109.0',
data_collection_permissions: {
required: [],
optional: ['technicalAndInteraction'],
},
},
},
}),
// Chrome-specific
...(browser === 'chrome' && {
minimum_chrome_version: '116',
}),
}), });
Per-Browser Entrypoint Options
// entrypoints/background.ts export default defineBackground({ // Different behavior per browser persistent: { firefox: true, // Use persistent background in Firefox chrome: false, // Service worker in Chrome },
main() { // ... }, });
Security Hardening Rules
Manifest Security (Rules 1-10)
Rule Rationale
1 Minimize permissions
Request only what's needed
2 Use optional_permissions
Request sensitive permissions at runtime
3 Scope host_permissions
Narrow to specific domains, never <all_urls>
4 Set minimum_chrome_version
Ensure security features are available
5 Avoid externally_connectable wildcards Limit which sites can message extension
6 Set strict CSP No unsafe-eval , no external scripts
7 Use web_accessible_resources sparingly Fingerprinting risk
8 Never expose source maps Hide implementation details
9 Remove debug permissions in production e.g., management , debugger
10 Validate manifest with wxt build --analyze
Catch permission bloat
Content Script Security (Rules 11-20)
Rule Rationale
11 Use Shadow DOM for injected UI Style isolation, DOM encapsulation
12 Never use innerHTML with untrusted data XSS prevention
13 Validate all messages from page Don't trust window.postMessage
14 Use ContentScriptContext for cleanup Prevent memory leaks
15 Avoid storing sensitive data in DOM Page scripts can read it
16 Use document_idle over document_start
Less intrusive, more stable
17 Scope CSS selectors narrowly Avoid page conflicts
18 Never inject into banking/payment pages High-risk surfaces
19 Use MutationObserver over polling Performance
20 Validate URL before injecting Prevent injection on wrong pages
Background Script Security (Rules 21-30)
Rule Rationale
21 Persist state to chrome.storage
Service worker terminates
22 Use chrome.alarms over setInterval
Survives worker restart
23 Validate all incoming messages Don't trust content scripts
24 Never store secrets in code Use secure storage
25 Use HTTPS for all fetch requests Data in transit security
26 Implement rate limiting Prevent abuse
27 Log security events Audit trail
28 Handle extension update gracefully Reconnect content scripts
29 Use webRequest carefully Performance impact
30 Avoid long-running operations Service worker termination
Storage Security (Rules 31-40)
Rule Rationale
31 Use storage.local for sensitive data Not synced to cloud
32 Encrypt sensitive values Defense in depth
33 Implement storage quotas Prevent unbounded growth
34 Validate data before storing Type safety
35 Use versioned schema migrations Data integrity
36 Clear storage on uninstall User privacy
37 Don't store PII without consent GDPR/CCPA compliance
38 Use storage.session for temporary data Auto-cleared
39 Implement backup/restore Data recovery
40 Audit storage access Security logging
Communication Security (Rules 41-49)
Rule Rationale
41 Use runtime.sendMessage over postMessage
Type-safe, scoped
42 Validate sender in message handlers Prevent spoofing
43 Never pass functions in messages Serialization issues
44 Chunk large data transfers Memory efficiency
45 Use typed message protocols Maintainability
46 Implement request timeouts Prevent hanging
47 Handle disconnection gracefully Tab closed, extension disabled
48 Don't expose internal APIs externally Use separate handlers
49 Log and monitor message patterns Detect anomalies
Firefox-Specific Patterns
Required Gecko Settings
// wxt.config.ts manifest: { browser_specific_settings: { gecko: { // Required for AMO submission id: 'my-extension@example.com',
// Version constraints
strict_min_version: '109.0',
// Data collection (required since Nov 2025)
data_collection_permissions: {
required: [],
optional: ['technicalAndInteraction'],
},
},
// Firefox for Android
gecko_android: {
strict_min_version: '120.0',
},
}, }
Firefox MV3 Differences
Feature Chrome MV3 Firefox MV3
Background Service worker only Event page supported
Persistent No Optional with persistent: true
browser API Promisified polyfill needed Native promises
DNR Full support Partial support
Side Panel Supported Not supported
Firefox-Specific Build
Build for Firefox only
wxt build -b firefox
Build MV2 for Firefox (if needed)
wxt build -b firefox --mv2
Handling Firefox Differences
// utils/browser-detect.ts export const isFirefox = navigator.userAgent.includes('Firefox');
// entrypoints/background.ts export default defineBackground({ persistent: isFirefox, // Keep background alive in Firefox
main() { if (isFirefox) { // Firefox-specific initialization } }, });
Safari-Specific Patterns
Xcode Project Requirements
Safari extensions require an Xcode host app:
Convert existing extension to Safari
xcrun safari-web-extension-converter /path/to/extension
--project-location /path/to/output
--app-name "My Extension"
--bundle-identifier com.example.myextension
Privacy Manifest (Required)
Every Safari extension host app needs PrivacyInfo.xcprivacy :
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>NSPrivacyTracking</key> <false/> <key>NSPrivacyTrackingDomains</key> <array/> <key>NSPrivacyCollectedDataTypes</key> <array/> <key>NSPrivacyAccessedAPITypes</key> <array/> </dict> </plist>
Safari Limitations
Feature Status Workaround
Side Panel Not supported Use popup
declarativeNetRequest
Limited Use webRequest
offscreen API Not supported Use content script
Persistent background Not supported State persistence
chrome.scripting.executeScript
Limited Declare in manifest
Safari Build Workflow
1. Build extension
wxt build -b safari
2. Convert to Xcode project
xcrun safari-web-extension-converter dist/safari-mv3
--project-location safari-app
3. Open in Xcode
open safari-app/MyExtension.xcodeproj
4. Add PrivacyInfo.xcprivacy to host app target
5. Archive and submit to App Store
TestFlight Distribution
As of 2025, Safari extensions can be submitted as ZIP files to App Store Connect for TestFlight testing without needing Xcode locally.
Storage Patterns
Using WXT Storage Utility
// utils/storage.ts import { storage } from 'wxt/storage';
// Define typed storage items export const userSettings = storage.defineItem<{ theme: 'light' | 'dark'; notifications: boolean; }>('local:settings', { defaultValue: { theme: 'light', notifications: true, }, });
export const sessionData = storage.defineItem<string[]>( 'session:recentTabs', { defaultValue: [] } );
// Usage const settings = await userSettings.getValue(); await userSettings.setValue({ ...settings, theme: 'dark' });
// Watch for changes userSettings.watch((newValue, oldValue) => { console.log('Settings changed:', newValue); });
Storage Migrations
// utils/storage.ts import { storage } from 'wxt/storage';
export const userPrefs = storage.defineItem('local:prefs', { defaultValue: { version: 2, theme: 'system' },
migrations: [ // v1 -> v2: renamed 'darkMode' to 'theme' { version: 2, migrate(oldValue: { darkMode?: boolean }) { return { version: 2, theme: oldValue.darkMode ? 'dark' : 'light', }; }, }, ], });
Testing Patterns
Unit Testing with Vitest
// tests/background.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fakeBrowser } from 'wxt/testing';
describe('background script', () => { beforeEach(() => { fakeBrowser.reset(); });
it('handles getData message', async () => { // Setup fake response fakeBrowser.storage.local.get.mockResolvedValue({ data: 'test' });
// Import and run background script
await import('../entrypoints/background');
// Simulate message
const [listener] = fakeBrowser.runtime.onMessage.addListener.mock.calls[0];
const response = await new Promise((resolve) => {
listener({ type: 'getData' }, {}, resolve);
});
expect(response).toEqual({ data: 'test' });
}); });
E2E Testing
// tests/e2e/extension.test.ts import { test, expect, chromium } from '@playwright/test'; import path from 'path';
test('popup shows correct UI', async () => { const extensionPath = path.join(__dirname, '../../dist/chrome-mv3');
const context = await chromium.launchPersistentContext('', {
headless: false,
args: [
--disable-extensions-except=${extensionPath},
--load-extension=${extensionPath},
],
});
// Get extension ID const [background] = context.serviceWorkers(); const extensionId = background.url().split('/')[2];
// Open popup
const popup = await context.newPage();
await popup.goto(chrome-extension://${extensionId}/popup.html);
await expect(popup.locator('h1')).toHaveText('My Extension'); });
Production Checklist
Before Build
-
Remove console.log statements
-
Set production environment variables
-
Verify all permissions are necessary
-
Test on all target browsers
-
Run security audit (npm audit )
-
Check bundle size (wxt build --analyze )
Manifest Validation
-
Extension name and description are accurate
-
Icons in all required sizes (16, 32, 48, 128)
-
Version follows semver
-
Gecko ID set for Firefox
-
Privacy manifest for Safari
-
CSP is strict (no unsafe-eval)
Cross-Browser Build
Build all browsers
wxt build -b chrome wxt build -b firefox wxt build -b safari wxt build -b edge
Package for submission
wxt zip -b chrome wxt zip -b firefox
References
-
WXT Documentation
-
MDN: browser_specific_settings
-
Firefox MV3 Migration Guide
-
Apple: Privacy Manifest Files
-
Apple: Safari Web Extensions