| name | wxt-framework-patterns |
| description | Comprehensive WXT browser extension framework patterns, security hardening rules, and cross-browser configuration |
| created | 2026-02-20T00:00:00.000Z |
| updated | 2026-02-20T00:00:00.000Z |
| tags | ["wxt","browser-extension","manifest-v3","vite","cross-browser","security"] |
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)
export default defineBackground(() => {
console.log('Extension loaded', { id: browser.runtime.id });
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'getData') {
handleGetData(message.payload).then(sendResponse);
return true;
}
});
browser.alarms.create('sync', { periodInMinutes: 5 });
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'sync') {
performSync();
}
});
});
Content Script
export default defineContentScript({
matches: ['*://*.example.com/*'],
runAt: 'document_idle',
main(ctx) {
console.log('Content script loaded');
ctx.onInvalidated(() => {
console.log('Extension updated/disabled');
cleanup();
});
const ui = createShadowRootUi(ctx, {
name: 'my-extension-ui',
position: 'inline',
anchor: '#target-element',
onMount(container) {
return mount(App, { target: container });
},
onRemove(app) {
app.$destroy();
},
});
ui.mount();
},
});
Content Script with Main World Access
export default defineContentScript({
matches: ['*://*.example.com/*'],
world: 'MAIN',
main() {
window.myExtensionApi = {
getData: () => { }
};
},
});
Popup with Framework
<!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>
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');
Configuration
Basic Configuration
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
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: ({ browser }) => ({
name: 'My Extension',
description: 'Cross-browser extension',
...(browser === 'firefox' && {
browser_specific_settings: {
gecko: {
id: 'my-extension@example.com',
strict_min_version: '109.0',
data_collection_permissions: {
required: [],
optional: ['technicalAndInteraction'],
},
},
},
}),
...(browser === 'chrome' && {
minimum_chrome_version: '116',
}),
}),
});
Per-Browser Entrypoint Options
export default defineBackground({
persistent: {
firefox: true,
chrome: false,
},
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
manifest: {
browser_specific_settings: {
gecko: {
id: 'my-extension@example.com',
strict_min_version: '109.0',
data_collection_permissions: {
required: [],
optional: ['technicalAndInteraction'],
},
},
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
wxt build -b firefox
wxt build -b firefox --mv2
Handling Firefox Differences
export const isFirefox = navigator.userAgent.includes('Firefox');
export default defineBackground({
persistent: isFirefox,
main() {
if (isFirefox) {
}
},
});
Safari-Specific Patterns
Xcode Project Requirements
Safari extensions require an Xcode host app:
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
wxt build -b safari
xcrun safari-web-extension-converter dist/safari-mv3 \
--project-location safari-app
open safari-app/MyExtension.xcodeproj
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
import { storage } from 'wxt/storage';
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: [] }
);
const settings = await userSettings.getValue();
await userSettings.setValue({ ...settings, theme: 'dark' });
userSettings.watch((newValue, oldValue) => {
console.log('Settings changed:', newValue);
});
Storage Migrations
import { storage } from 'wxt/storage';
export const userPrefs = storage.defineItem('local:prefs', {
defaultValue: { version: 2, theme: 'system' },
migrations: [
{
version: 2,
migrate(oldValue: { darkMode?: boolean }) {
return {
version: 2,
theme: oldValue.darkMode ? 'dark' : 'light',
};
},
},
],
});
Testing Patterns
Unit Testing with Vitest
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fakeBrowser } from 'wxt/testing';
describe('background script', () => {
beforeEach(() => {
fakeBrowser.reset();
});
it('handles getData message', async () => {
fakeBrowser.storage.local.get.mockResolvedValue({ data: 'test' });
await import('../entrypoints/background');
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
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}`,
],
});
const [background] = context.serviceWorkers();
const extensionId = background.url().split('/')[2];
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
Manifest Validation
Cross-Browser Build
wxt build -b chrome
wxt build -b firefox
wxt build -b safari
wxt build -b edge
wxt zip -b chrome
wxt zip -b firefox
References