Building Chrome Extensions with Manifest V3: WXT vs Plasmo 2026
A complete guide to building browser extensions in 2026. Learn why Manifest V3 matters, compare WXT vs Plasmo vs vanilla JS, and walk through building a real extension with service workers, content scripts, and storage APIs.
Manifest V3 landed as one of the most controversial browser changes in years. Chromeβs extension platform overhaul broke thousands of ad blockers, forced developers to rewrite background pages as service workers, and introduced new security constraints that confused nearly everyone.
Two years later, the dust has settled. Manifest V2 extensions are officially dead in Chrome β if youβre building a new extension in 2026, MV3 is the only option. And the build tooling has finally caught up: WXT and Plasmo have made MV3 development dramatically less painful.
This guide covers everything you need to build a production-ready browser extension in 2026.
Why Manifest V3 Matters: What Actually Changed
MV3 isnβt just a version bump. It changes the fundamental model of how extensions work.
Background Pages β Service Workers
The biggest breaking change: persistent background pages are gone. In MV2, your background script lived forever β it could maintain in-memory state, hold open WebSocket connections, and respond instantly to events.
Service workers terminate when idle (typically 30 seconds in Chrome). This means:
- No persistent state β you canβt rely on a global
let myCache = {}surviving between events - No long-running operations β a 5-minute API poll loop will get killed
- Async-first β every interaction must handle the service worker potentially being asleep
The workaround: persist everything to chrome.storage or use chrome.alarms for periodic tasks.
Declarative Net Request vs webRequest
The rule that broke ad blockers: MV3 replaced the webRequest blocking API (which could intercept and modify requests) with declarativeNetRequest β a declarative ruleset approach where you define rules ahead of time and the browser applies them.
For most extensions (password managers, dev tools, productivity apps), this change is irrelevant. For extensions that do dynamic network manipulation, you need to design your rules statically.
Content Security Policy Changes
MV3 bans remotely-hosted code. You canβt dynamically eval() scripts or load code from a CDN at runtime. Everything must be bundled with your extension. This pushes you toward proper build tooling β which is partly why WXT and Plasmo exist.
Choosing Your Build Tool: WXT vs Plasmo vs Vanilla
| Feature | WXT | Plasmo | Vanilla JS |
|---|---|---|---|
| TypeScript first | β | β | Manual |
| Cross-browser support | β Chrome, Firefox, Safari, Edge | β Chrome, Firefox, Edge | Manual |
| HMR dev mode | β | β | β |
| React/Vue/Svelte | β Any framework | β React first | Manual |
| File-based routing | β | β | β |
| Auto manifest gen | β | β | β |
| Plasmo storage SDK | β | β | β |
| Learning curve | Low | Low | Medium |
| Community size | Growing | Large | N/A |
| Bundle size | Configurable | Larger (React) | Smallest |
Choose WXT if you want framework flexibility (Vue, Svelte, or React all work), a clean file-based structure, and first-class Firefox support. WXT feels more like a standard web dev workflow.
Choose Plasmo if youβre React-heavy and want their ecosystem β messaging SDK, storage SDK, and CS UI component injection helpers. Plasmo has more batteries included.
Choose vanilla only if bundle size is critical (browser extension size limits are generous, so rarely needed) or youβre building something trivially simple.
For this guide, weβll use WXT β itβs the most actively developed in 2026 and produces the cleanest output.
Setting Up a WXT Project
pnpm create wxt@latest my-extension
cd my-extension
pnpm install
During setup, select:
- Framework: Vue / React / Svelte (or vanilla)
- TypeScript: Yes
- Package manager: Your preference
The generated structure:
my-extension/
βββ entrypoints/
β βββ background.ts # Service worker
β βββ content.ts # Content script
β βββ popup/
β β βββ index.html
β β βββ App.tsx
β βββ options/
β βββ index.html
β βββ App.tsx
βββ public/
β βββ icon-128.png
βββ wxt.config.ts
βββ package.json
WXT generates manifest.json automatically from your entrypoints and wxt.config.ts. No manual manifest maintenance.
Start the dev server:
pnpm dev
WXT opens Chrome with your extension loaded, with Hot Module Replacement. Edit a content script and it reloads automatically β a massive improvement over the manual extension reload cycle.
Key Concept: Service Workers
Your entrypoints/background.ts is a service worker. Treat it accordingly.
// entrypoints/background.ts
export default defineBackground(() => {
// This runs when the service worker starts
console.log('Extension loaded', browser.runtime.id);
// Listen for installation
browser.runtime.onInstalled.addListener(({ reason }) => {
if (reason === 'install') {
// Set defaults on first install
browser.storage.local.set({
enabled: true,
count: 0,
});
}
});
// Handle messages from content scripts
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_COUNT') {
browser.storage.local.get('count').then(({ count }) => {
sendResponse({ count });
});
return true; // Keep channel open for async response
}
});
});
Key service worker rules:
- Donβt trust in-memory state β save everything to
chrome.storage - Return
truefromonMessagelisteners if you respond asynchronously - Use
chrome.alarmsfor periodic work, notsetInterval
Key Concept: Content Scripts
Content scripts run in the context of web pages. They can read and modify the DOM, but they run in an isolated JavaScript sandbox (they canβt access page JavaScript variables directly).
// entrypoints/content.ts
export default defineContentScript({
matches: ['https://*.github.com/*'],
main() {
console.log('Content script running on GitHub');
// Inject UI into the page
const button = document.createElement('button');
button.textContent = 'Extension Action';
button.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
padding: 8px 16px;
background: #0969da;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
`;
button.addEventListener('click', async () => {
// Send message to background service worker
const response = await browser.runtime.sendMessage({ type: 'GET_COUNT' });
console.log('Count:', response.count);
});
document.body.appendChild(button);
},
});
The matches array uses match patterns β WXT uses this to generate the correct manifest entry.
Storage, Messaging, and Permissions
Storage
MV3 has two storage areas: local (device-only) and sync (syncs across devices via Chrome Sync, 100KB limit).
// Write
await browser.storage.local.set({ key: 'value', settings: { theme: 'dark' } });
// Read single key
const { key } = await browser.storage.local.get('key');
// Read multiple
const { key, settings } = await browser.storage.local.get(['key', 'settings']);
// Listen for changes
browser.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.settings) {
console.log('Settings changed:', changes.settings.newValue);
}
});
Messaging
Three messaging patterns in MV3:
One-time message (content script β background):
// Sender
const response = await browser.runtime.sendMessage({ type: 'ACTION', data: 'hello' });
// Receiver (background)
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'ACTION') {
sendResponse({ result: 'done' });
}
});
Background β specific content script tab:
// Get the active tab and send a message to its content script
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
await browser.tabs.sendMessage(tab.id!, { type: 'UPDATE_UI' });
Long-lived port (for streaming/persistent communication):
// Content script
const port = browser.runtime.connect({ name: 'stream' });
port.onMessage.addListener((msg) => console.log(msg));
port.postMessage({ type: 'START' });
// Background
browser.runtime.onConnect.addListener((port) => {
port.onMessage.addListener((msg) => {
if (msg.type === 'START') {
port.postMessage({ data: 'streaming...' });
}
});
});
Permissions
Declare permissions in wxt.config.ts:
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
permissions: ['storage', 'alarms', 'tabs'],
host_permissions: ['https://api.example.com/*'],
optional_permissions: ['history'], // Request at runtime
},
});
Request optional permissions at runtime (better for user trust):
const granted = await browser.permissions.request({
permissions: ['history'],
});
Building a Practical Tool Extension
Letβs build a Page Word Counter β it shows a badge with the word count of the current page and displays stats in a popup.
Background service worker β update the badge:
// entrypoints/background.ts
export default defineBackground(() => {
browser.runtime.onMessage.addListener((msg) => {
if (msg.type === 'UPDATE_BADGE') {
const count = msg.count;
const label = count > 999 ? `${Math.floor(count / 1000)}k` : String(count);
browser.action.setBadgeText({ text: label, tabId: msg.tabId });
browser.action.setBadgeBackgroundColor({ color: '#0969da', tabId: msg.tabId });
}
});
});
Content script β count words and report:
// entrypoints/content.ts
export default defineContentScript({
matches: ['<all_urls>'],
main() {
function countWords() {
const text = document.body.innerText || '';
const words = text.trim().split(/\s+/).filter(w => w.length > 0);
return words.length;
}
async function reportCount() {
const count = countWords();
const [tab] = await browser.tabs.getCurrent();
browser.runtime.sendMessage({
type: 'UPDATE_BADGE',
count,
tabId: tab?.id,
});
// Also save to storage for the popup
await browser.storage.local.set({ [`wordcount_${location.href}`]: count });
}
// Run on load and after navigation
reportCount();
const observer = new MutationObserver(() => reportCount());
observer.observe(document.body, { childList: true, subtree: true });
},
});
Popup β display stats:
// entrypoints/popup/App.tsx
import { useEffect, useState } from 'react';
export default function App() {
const [count, setCount] = useState<number | null>(null);
useEffect(() => {
browser.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
if (tab.url) {
browser.storage.local.get(`wordcount_${tab.url}`).then((result) => {
setCount(result[`wordcount_${tab.url}`] ?? 0);
});
}
});
}, []);
return (
<div style={{ padding: '16px', minWidth: '200px' }}>
<h2 style={{ margin: '0 0 8px' }}>Word Counter</h2>
{count === null ? (
<p>Loading...</p>
) : (
<p style={{ fontSize: '2rem', fontWeight: 'bold', margin: 0 }}>{count.toLocaleString()}</p>
)}
<p style={{ color: '#666', fontSize: '0.85rem' }}>words on this page</p>
</div>
);
}
Build it:
pnpm build
WXT outputs to .output/chrome-mv3/ β this is your packaged extension.
Publishing to Chrome Web Store + Firefox Add-ons
Chrome Web Store
- Build with
pnpm build - Zip the
.output/chrome-mv3/directory - Go to Chrome Web Store Developer Dashboard
- Pay the one-time $5 developer fee
- Upload the zip, fill in store listing (screenshots required: 1280Γ800 or 640Γ400)
- Submit for review β typically 1-3 business days
WXT has a built-in zip command:
pnpm zip # Creates .output/my-extension-1.0.0-chrome.zip
Firefox Add-ons
WXTβs Firefox output (pnpm build --browser firefox) generates MV2-compatible output (Firefox supports both MV2 and MV3 but handles MV2 better for now).
pnpm build --browser firefox
pnpm zip --browser firefox
Submit at addons.mozilla.org. Firefox requires source code submission for review β WXT generates a source zip automatically:
pnpm zip:sources
Safari
Safari extensions use Xcode. WXT can generate a Safari-compatible build, but youβll need a Mac and an Apple Developer account ($99/year).
2026 Tips for MV3 in Production
Handle service worker wake-up time. If a user hasnβt interacted with your extension in a while, the service worker may take 100-300ms to start. For user-facing actions, this is usually fine. For time-sensitive operations, consider using chrome.alarms to keep state fresh.
Use chrome.storage.session (new in Chrome 102) for data that should persist across service worker restarts within a session but not across browser restarts. Itβs faster than local and doesnβt persist to disk.
Side panel API (Chrome 114+) β WXT supports side panels as a new entrypoint type. Great for extensions that need persistent UI without blocking the tab.
Offscreen documents β need to play audio, parse HTML, or use canvas APIs? MV3 introduced offscreen documents as a workaround for service worker limitations:
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: [chrome.offscreen.Reason.AUDIO_PLAYBACK],
justification: 'Playing notification sound',
});
What to Build
The MV3 ecosystem is still young, which means opportunity. High-value extension categories in 2026:
- AI integration β inject AI assistants into any web app (many LLM APIs now have CORS headers)
- Dev tools β request inspectors, localStorage managers, framework debuggers
- Productivity β tab managers, focus tools, reading time estimators
- Accessibility β font size overrides, contrast adjusters, screen reader helpers
The Chrome Web Store has 180,000+ extensions, but most are outdated MV2 builds. An actively maintained MV3 alternative to any popular extension is a real opportunity.
Summary
Manifest V3 is a fundamentally different mental model from MV2 β service workers instead of persistent background pages, declarative network rules instead of blocking APIs, bundled code instead of remote scripts. The constraints are tighter, but the security model is better.
WXT and Plasmo have made MV3 approachable. You get TypeScript, HMR, automatic manifest generation, and cross-browser builds out of the box. Pick WXT for flexibility, Plasmo for a React-focused batteries-included experience.
The stack for a production extension in 2026: WXT + TypeScript + React/Vue + chrome.storage + proper permission scoping. Build it once, ship to Chrome, Firefox, and Edge from a single codebase.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.