// "Apply modern browser extension toolchain patterns: WXT (default), Plasmo, CRXJS for Chrome/Firefox/Safari extensions. Use when building browser extensions, choosing extension frameworks, or discussing manifest v3 patterns."
| name | extension-toolchain |
| description | Apply modern browser extension toolchain patterns: WXT (default), Plasmo, CRXJS for Chrome/Firefox/Safari extensions. Use when building browser extensions, choosing extension frameworks, or discussing manifest v3 patterns. |
Modern browser extension development with Manifest V3, focusing on framework-agnostic solutions.
Why WXT (2025):
# Create new extension
npm create wxt@latest
# Choose your framework
? Select a template:
> vanilla
react
vue
svelte
solid
# Start development
cd my-extension
npm run dev # Chrome (default)
npm run dev:firefox # Firefox
npm run dev:edge # Edge
npm run dev:safari # Safari (experimental)
โ Multi-framework teams (framework-agnostic) โ Need cross-browser compatibility โ Want modern DX (HMR, TypeScript, auto-reload) โ Publishing to multiple stores โ Complex extensions with multiple entry points
Best for React developers:
# Create Plasmo extension
npm create plasmo
# Start development
npm run dev
โ React-only team โ Want Next.js-like DX โ Need remote code bundling โ Prefer opinionated frameworks
Minimal, unopinionated:
# Add to existing Vite project
npm install @crxjs/vite-plugin -D
โ Want maximum control โ Already using Vite โ Minimal tooling preference โ Expert developer team
| WXT | Plasmo | CRXJS | |
|---|---|---|---|
| Frameworks | All | React-focused | All |
| Setup | Batteries-included | Opinionated | Manual |
| DX | Excellent | Excellent | Great |
| HMR | Yes | Yes | Best |
| Auto-publish | Yes | Yes | No |
| Learning Curve | Low | Low | Medium |
| Flexibility | High | Medium | Highest |
my-extension/
โโโ entrypoints/
โ โโโ background.ts # Service worker
โ โโโ content.ts # Content script
โ โโโ popup/ # Extension popup
โ โ โโโ index.html
โ โ โโโ main.tsx
โ โโโ options/ # Options page
โ โโโ index.html
โ โโโ main.tsx
โโโ components/ # Shared UI components
โโโ utils/ # Shared utilities
โโโ public/ # Static assets
โ โโโ icon.png # Extension icon
โโโ wxt.config.ts # WXT configuration
โโโ package.json
// wxt.config.ts
import { defineConfig } from 'wxt'
export default defineConfig({
manifest: {
name: 'My Extension',
version: '1.0.0',
permissions: ['storage', 'tabs'],
host_permissions: ['https://*.example.com/*'],
action: {
default_title: 'My Extension',
},
},
})
host_permissions separate from permissionsscripting API for dynamic content script injectionexecuteScript capabilities// popup/main.tsx
import browser from 'webextension-polyfill'
const response = await browser.runtime.sendMessage({
type: 'GET_DATA',
payload: { key: 'value' },
})
// background.ts
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_DATA') {
// Process and respond
sendResponse({ data: 'result' })
}
return true // Keep channel open for async response
})
// content.ts
import browser from 'webextension-polyfill'
// Send message to background
const result = await browser.runtime.sendMessage({
type: 'ANALYZE_PAGE',
url: window.location.href,
})
// background.ts
browser.runtime.onMessage.addListener(async (message) => {
if (message.type === 'ANALYZE_PAGE') {
const analysis = await analyzePage(message.url)
return { analysis }
}
})
// content.ts - inject into page context
const script = document.createElement('script')
script.src = browser.runtime.getURL('injected.js')
document.head.appendChild(script)
// Listen for messages from page
window.addEventListener('message', (event) => {
if (event.source !== window) return
if (event.data.type === 'FROM_PAGE') {
// Handle message from page
}
})
// injected.js (runs in page context, has access to page's window/DOM)
window.postMessage({ type: 'FROM_PAGE', data: 'value' }, '*')
// Using chrome.storage.sync (syncs across devices)
import browser from 'webextension-polyfill'
// Save
await browser.storage.sync.set({ key: 'value' })
// Load
const { key } = await browser.storage.sync.get('key')
// Listen for changes
browser.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'sync' && changes.key) {
console.log('Value changed:', changes.key.newValue)
}
})
# Cross-browser compatibility
npm install webextension-polyfill
# State Management
npm install zustand
# Forms
npm install react-hook-form zod
# UI Components (if using React)
npm install @radix-ui/react-* # Headless components
# Install testing libraries
npm install --save-dev vitest @testing-library/react @testing-library/user-event
npm install --save-dev @wxt-dev/testing
Example test:
// popup/main.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Popup from './main'
describe('Popup', () => {
it('renders heading', () => {
render(<Popup />)
expect(screen.getByRole('heading')).toBeInTheDocument()
})
})
# .github/workflows/extension-ci.yml
name: Extension CI
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: extension-build
path: .output/
# Build for all browsers
npm run build # Chrome
npm run build:firefox # Firefox
npm run build:safari # Safari
# Zip for submission
npm run zip # All stores
# Or use WXT's publish command (requires API keys)
wxt publish --chrome --firefox
Store submission setup:
// wxt.config.ts
export default defineConfig({
zip: {
artifactTemplate: '{{name}}-{{version}}-{{browser}}.zip',
},
manifest: {
name: '__MSG_extName__',
description: '__MSG_extDescription__',
default_locale: 'en',
},
})
// Content Security Policy
manifest: {
content_security_policy: {
extension_pages: "script-src 'self'; object-src 'self'"
}
}
// Validate messages
browser.runtime.onMessage.addListener((message) => {
// Always validate message structure
if (typeof message !== 'object' || !message.type) {
return
}
// Type guard
if (message.type === 'EXPECTED_TYPE') {
// Process
}
})
// Never inject user content directly into DOM
// Use textContent, not innerHTML
element.textContent = userInput // Safe
element.innerHTML = userInput // XSS vulnerability!
Service Worker Lifecycle:
chrome.storage for persistence, not in-memory stateContent Script Isolation:
postMessage to communicate with pageManifest V3 Restrictions:
eval() or new Function()New browser extension:
โโ Multi-framework team โ WXT โ
โโ React-only team โ Plasmo
โโ Want maximum control โ CRXJS
Existing extension (Manifest V2):
โโ Migrate to WXT (handles V2โV3 migration)
When agents design browser extensions, they should: