Overview
Transform your extension development workflow with WXT - a modern framework that provides hot reloading, cross-browser compatibility, TypeScript support, and optimized builds. This lesson replaces the manual reload cycle with professional tooling while maintaining the same extension functionality.
By the end, you’ll have a development environment that reloads automatically and supports both Chrome and Firefox from a single codebase.
What You’ll Learn
- Install and configure WXT for extension development
- Understand WXT’s file-based routing and conventions
- Use hot module reloading for instant feedback
- Write browser-agnostic code that works in Chrome and Firefox
- Navigate WXT’s project structure and build output
- Rebuild the Lesson 2 extension using WXT best practices
Why WXT?
Vanilla extension development has significant pain points:
Manual Reloading: Every code change requires clicking reload in chrome://extensions
Browser-Specific APIs: chrome.tabs (Chrome) vs browser.tabs (Firefox) requires conditional code
No Build Tools: Missing TypeScript, hot reloading, and modern bundling
WXT solves these problems while letting you write standard extension code - it’s not a wrapper or abstraction, just better tooling.
What is WXT?
WXT is a next-generation web extension framework built on Vite. It provides:
- Hot Module Reloading - Changes appear instantly without manual reloads
- Cross-Browser Support - One codebase for Chrome, Firefox, Edge, Safari
- TypeScript Built-in - Full type safety with zero configuration
- File-Based Routing - Convention over configuration for extension structure
- Production Builds - Optimized bundles for store submission
Official Docs: wxt.dev
The framework is opinionated but follows extension best practices. If you follow WXT’s conventions, development becomes significantly faster.
Installing WXT
Create a New Project
Use your preferred package manager to initialize a WXT project:
# Using bun (fastest)
bun create wxt@latest
# Using npm
npm create wxt@latest
# Using pnpm
pnpm create wxt@latest
# Using yarn
yarn create wxt
Project Setup Prompts:
- Project name:
tabnotes - Template:
vanilla(we’ll add frameworks later if needed) - Package manager: Choose your preference (examples use
bun)
Install Dependencies
cd tabnotes
bun install # or npm install, pnpm install, yarn
Starting the Development Server
Run the dev server to launch a Chrome instance with your extension pre-loaded:
bun dev # or npm run dev, pnpm dev, yarn dev
What Happens:
- WXT starts a development server
- A new Chrome window opens automatically
- Extension is pre-installed and active
- Changes to code trigger automatic reloads
The extension opens in an isolated Chrome profile - your personal Chrome browsing remains separate.
Exploring the Starter Extension
Click the extension icon (you may need to pin it from the puzzle icon menu). The starter includes:
- Animated popup with a counter
- Background script logging events
- Content script injecting on google.com
This demonstrates WXT’s capabilities - we’ll simplify to match our Lesson 2 extension.
Understanding WXT’s Project Structure
tabnotes/
├── .output/ # Generated extension files (auto-created)
├── assets/ # Static assets bundled with code
├── components/ # Reusable UI components
├── entrypoints/ # Extension entry points (KEY FOLDER)
│ ├── background.ts # Background script / service worker
│ ├── content.ts # Content scripts
│ └── popup/ # Popup UI
│ ├── index.html
│ ├── main.ts
│ └── style.css
├── public/ # Static files copied as-is
│ └── icon/ # Extension icons
├── wxt.config.ts # WXT configuration
└── package.json # Project dependencies and scripts
Key Concepts
entrypoints/ Directory:
WXT uses file-based routing. Files in entrypoints/ automatically become extension components:
background.ts→ Background scriptpopup/→ Popup interfacecontent.ts→ Content scriptoptions.ts→ Options page (if created)
No need to register these in manifest.json - WXT handles it.
.output/ Directory:
WXT generates the final extension here:
.output/chrome-mv3-dev/- Development build for Chrome.output/firefox-mv3-dev/- Development build for Firefox- Contains auto-generated
manifest.jsonand bundled files
How Hot Reloading Works
Make a change to entrypoints/popup/index.html - maybe change the heading text. Save the file.
Instant Update: The popup refreshes automatically without clicking reload.
Behind the Scenes
- WXT watches source files for changes
- On change, Vite rebuilds affected modules
- WXT updates
.output/chrome-mv3-dev/ - Extension reloads automatically in Chrome
Limitations:
Hot reloading works for most changes:
- ✅ HTML, CSS, JavaScript edits
- ✅ Content script modifications
- ❌ Manifest/config changes (requires restart)
- ❌ Background script updates (requires restart)
For manifest or background changes: Ctrl+C to stop dev server, then bun dev to restart.
The Auto-Generated Manifest
Examine .output/chrome-mv3-dev/manifest.json:
{
"manifest_version": 3,
"name": "tabnotes",
"version": "1.0.0",
"description": "",
"permissions": ["storage"],
"action": {
"default_popup": "popup/index.html"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [{
"matches": ["https://google.com/*"],
"js": ["content-scripts/content.js"]
}]
}
You didn’t write this - WXT generated it from your project structure and config.
Why Auto-Generation Matters
Different browsers require different manifest formats:
- Chrome uses
service_workerfor background - Firefox uses
scriptsarray for background
WXT generates the correct format for each browser target automatically.
Writing Cross-Browser Code
The browser vs chrome API
In vanilla extensions, you write:
// Chrome-specific
chrome.tabs.onActivated.addListener(...)
// Firefox-specific
browser.tabs.onActivated.addListener(...)
WXT’s Solution: Always use browser - WXT polyfills it for Chrome:
// Works in both Chrome and Firefox
browser.tabs.onActivated.addListener(async (activeInfo) => {
const tab = await browser.tabs.get(activeInfo.tabId);
console.log('Tab activated:', tab);
});
WXT automatically converts browser to chrome when building for Chrome.
Rebuilding the Lesson 2 Extension
Let’s simplify the starter to match our basic tab-detection extension.
Step 1: Clean Up the Popup
Replace entrypoints/popup/index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>TabNotes</title>
</head>
<body>
<h1>TabNotes</h1>
</body>
</html>
Delete these unnecessary files:
entrypoints/popup/main.ts(we’ll add logic later)entrypoints/popup/style.css(we’ll style later)entrypoints/content.ts(not needed yet)components/folder (not needed yet)assets/folder (not needed yet)
Step 2: Update Background Script
Replace entrypoints/background.ts:
// entrypoints/background.ts
export default defineBackground(() => {
browser.tabs.onActivated.addListener(async (activeInfo) => {
const tab = await browser.tabs.get(activeInfo.tabId);
console.log('Tab activated:', tab);
});
});
WXT-Specific Pattern: The defineBackground() wrapper is WXT’s convention for background scripts. It provides proper TypeScript types and handles service worker registration.
Step 3: Configure Permissions
Update wxt.config.ts:
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
permissions: ['tabs']
}
});
WXT merges this config with auto-generated manifest values.
Step 4: Test the Extension
Your dev server should auto-reload. If not, restart with Ctrl+C then bun dev.
Verify it works:
- Extension popup shows “TabNotes” heading
- Click “Inspect service worker” in
chrome://extensions - Switch between browser tabs
- Console logs each tab activation
Congratulations! You’ve rebuilt the Lesson 2 extension with modern tooling.
WXT Project Configuration
The wxt.config.ts file controls WXT’s behavior:
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
name: 'TabNotes',
version: '1.0',
description: 'Add notes to any tab in your browser',
permissions: ['tabs'],
},
});
We’ll expand this config as we add features. For now, the minimal config is sufficient.
WXT vs Vanilla Development
Vanilla Extension (Lesson 2)
- ❌ Manual reload after every change
- ❌ Chrome-specific code (
chrome.tabs) - ❌ Manual manifest management
- ❌ No TypeScript support
- ❌ No build optimization
WXT Extension (Lesson 3)
- ✅ Automatic hot reloading
- ✅ Cross-browser compatible (
browser.tabs) - ✅ Auto-generated manifest
- ✅ TypeScript with full type safety
- ✅ Vite-powered builds
The same extension functionality, dramatically better developer experience.
Understanding the .output Directory
WXT generates browser-specific builds in .output/:
.output/
├── chrome-mv3-dev/
│ ├── manifest.json # Chrome-formatted manifest
│ ├── background.js # Bundled background script
│ └── popup/
│ └── index.html
└── firefox-mv3-dev/
├── manifest.json # Firefox-formatted manifest
└── ...
Development Mode: Files include source maps and dev tooling
Production Mode: Run bun run build for optimized, minified bundles
Troubleshooting
Extension Doesn’t Appear
If the extension disappears from the dev Chrome window:
- Press
Ctrl+Cto stop dev server - Run
bun devto restart - Extension should reappear
This occasionally happens with manifest/config changes.
TypeScript Errors
WXT provides full type definitions for extension APIs:
// ❌ TypeScript error: Property 'url' might be undefined
const url = tab.url.toLowerCase();
// ✅ Safe with optional chaining
const url = tab.url?.toLowerCase() ?? '';
Trust TypeScript’s type checking - it prevents runtime errors.
Hot Reload Not Working
If changes don’t reflect automatically:
- ✅ HTML/CSS/basic JS: Should hot reload
- ❌ Config/manifest changes: Restart dev server
- ❌ Background script: Restart dev server
What’s Next
You now have a modern development environment with hot reloading and cross-browser support. The extension works identically to Lesson 2 but with dramatically improved tooling.
Lesson 4 dives deep into manifest configuration - understanding permissions, host permissions, and the security model that governs what your extension can access.
From this point forward, all development uses WXT. The manual reload cycle is behind you.