home / skills / phrazzld / claude-config / extension-toolchain
This skill helps you choose and implement modern browser extension toolchains like WXT, Plasmo, or CRXJS for MV3 development.
npx playbooks add skill phrazzld/claude-config --skill extension-toolchainReview the files below or copy the command above to add this skill to your agents.
---
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."
effort: high
---
# Extension Toolchain
Modern browser extension development with Manifest V3, focusing on framework-agnostic solutions.
## Recommended Stack: WXT (Default)
**Why WXT (2025):**
- Framework-agnostic (React, Vue, Svelte, SolidJS, Vanilla)
- Vite-powered (fast HMR, optimized builds)
- Auto-reload on code changes (content scripts too!)
- TypeScript-first with excellent type generation
- Automated publishing to stores
- Manifest V3 by default
```bash
# 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)
```
### When to Use WXT
✅ 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
## Alternative: Plasmo
**Best for React developers:**
- Next.js-like file-based routing
- Automatic code splitting
- Built-in remote code bundling
- Very opinionated (React-centric)
```bash
# Create Plasmo extension
npm create plasmo
# Start development
npm run dev
```
### When to Use Plasmo
✅ React-only team
✅ Want Next.js-like DX
✅ Need remote code bundling
✅ Prefer opinionated frameworks
## Alternative: CRXJS (Vite Plugin)
**Minimal, unopinionated:**
- Just a Vite plugin (you control everything)
- Best-in-class HMR (especially for content scripts)
- Lightweight, minimal overhead
- Requires more manual setup
```bash
# Add to existing Vite project
npm install @crxjs/vite-plugin -D
```
### When to Use CRXJS
✅ Want maximum control
✅ Already using Vite
✅ Minimal tooling preference
✅ Expert developer team
## Toolchain Comparison
| | 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 |
## Project Structure (WXT)
```
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
```
## Manifest V3 Essentials
```typescript
// 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',
},
},
})
```
### Key Manifest V3 Changes
- **Service Workers** replace background pages (no DOM access)
- **`host_permissions`** separate from `permissions`
- **`scripting` API** for dynamic content script injection
- **No remotely hosted code** (bundle everything)
- **Limited `executeScript`** capabilities
## Communication Patterns
### Popup ↔ Background
```typescript
// 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 Script ↔ Background
```typescript
// 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 Script ↔ Page (Web Page)
```typescript
// 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' }, '*')
```
## Storage Patterns
```typescript
// 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)
}
})
```
## Essential Libraries
```bash
# 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
```
## Testing Strategy
```bash
# Install testing libraries
npm install --save-dev vitest @testing-library/react @testing-library/user-event
npm install --save-dev @wxt-dev/testing
```
**Example test:**
```typescript
// 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()
})
})
```
## Quality Gates Integration
```yaml
# .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/
```
## Publishing Automation
```bash
# 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:**
```typescript
// wxt.config.ts
export default defineConfig({
zip: {
artifactTemplate: '{{name}}-{{version}}-{{browser}}.zip',
},
manifest: {
name: '__MSG_extName__',
description: '__MSG_extDescription__',
default_locale: 'en',
},
})
```
## Performance Best Practices
- **Lazy load content scripts**: Only inject when needed
- **Use storage efficiently**: Minimize sync storage writes
- **Debounce frequent operations**: Especially in content scripts
- **Minimize background script work**: Use alarms/events, not intervals
- **Optimize bundle size**: Code splitting, tree shaking
## Security Considerations
```typescript
// 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!
```
## Common Gotchas
**Service Worker Lifecycle:**
- Service workers can be terminated anytime
- Use `chrome.storage` for persistence, not in-memory state
- Set up event listeners at top level (not inside async functions)
**Content Script Isolation:**
- Content scripts run in isolated world
- No direct access to page's JavaScript
- Must use `postMessage` to communicate with page
**Manifest V3 Restrictions:**
- No `eval()` or `new Function()`
- No inline scripts in HTML
- All code must be bundled
- Limited service worker APIs
## Recommendation Flow
```
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:
- Default to WXT for new projects (framework-agnostic, best DX)
- Use Manifest V3 (V2 deprecated in 2024)
- Apply quality-gates skill for testing/CI setup
- Use webextension-polyfill for cross-browser compatibility
- Follow Content Security Policy strictly
- Plan for service worker lifecycle (no persistent background page)
- Use chrome.storage for state persistence
- Validate all messages between components
This skill prescribes a modern, framework-agnostic toolchain for building Manifest V3 browser extensions, with WXT as the default recommendation and Plasmo and CRXJS as alternatives. It focuses on fast DX, cross-browser builds, TypeScript-first workflows, and safe Manifest V3 patterns. The guidance covers project structure, communication patterns, storage, testing, CI, publishing, performance, and security.
The skill inspects the extension requirements and recommends WXT by default for multi-framework teams and cross-browser targets, Plasmo for React-first projects, or CRXJS when you want minimal Vite-plugin control. It describes development commands, manifest configuration, service-worker patterns, message-passing idioms, storage usage, testing setups, CI gates, and automated publishing flows. It also highlights runtime constraints and security best practices specific to Manifest V3.
Which toolchain should I pick for small single-framework extensions?
If you use React only and want opinionated DX, Plasmo is a good fit. For minimal tooling and full control, CRXJS is best. For general simplicity and cross-framework support, WXT remains the default.
How do I handle background state given service worker lifecycle?
Persist important state to chrome.storage (sync or local). Register event listeners at top level so they rehydrate when the service worker starts, and avoid relying on in-memory persistence.