wxt-framework-patterns
SKILL.md
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-submissionskill)
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
Weekly Installs
9
Repository
arustydev/aiGitHub Stars
6
First Seen
Feb 21, 2026
Security Audits
Installed on
opencode9
antigravity9
claude-code9
github-copilot9
codex9
zencoder9