Chrome extensions in 2026 all run under Manifest V3, and the transition from MV2 has changed the architecture significantly. Service workers replaced persistent background pages, declarativeNetRequest replaced blocking webRequest, and the permission model got stricter. Once you understand the MV3 model, building an extension is straightforward. Here is the complete path.
What changed in 2026
- MV2 is fully disabled in Chrome stable. The migration period ended; all extensions must be MV3.
- Side panel API (stable since Chrome 116) lets extensions show a persistent panel alongside the page — the replacement for many use cases that previously used popups.
chrome.offscreen API solves the "service workers cannot access the DOM" problem for cases like audio playback or clipboard access.
- CRXJS Vite plugin matured to v2 and is now the standard build setup for React/TypeScript extensions.
Extension architecture (MV3)
my-extension/
├── manifest.json # Extension metadata and permissions
├── background/
│ └── service-worker.ts # Ephemeral background logic
├── content/
│ └── content-script.ts # Runs in the context of web pages
├── popup/
│ ├── popup.html
│ └── popup.tsx # React popup UI
├── options/
│ └── options.tsx # Settings page
└── icons/
└── icon-128.png
manifest.json (MV3)
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "Does a specific useful thing.",
"permissions": ["storage", "activeTab"],
"host_permissions": ["https://example.com/*"],
"background": {
"service_worker": "background/service-worker.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["content/content-script.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup/popup.html",
"default_icon": "icons/icon-128.png"
},
"icons": {
"128": "icons/icon-128.png"
}
}
Build setup with Vite + CRXJS
npm create vite@latest my-extension -- --template react-ts
npm install -D @crxjs/vite-plugin
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';
export default defineConfig({
plugins: [react(), crx({ manifest })],
});
Development with hot reload:
npm run dev
# Load the `dist/` folder in chrome://extensions as an unpacked extension
Service worker (background)
Service workers in MV3 terminate after ~30 seconds of inactivity. Do not store state in variables — use chrome.storage:
// background/service-worker.ts
chrome.runtime.onInstalled.addListener(() => {
chrome.storage.local.set({ count: 0 });
});
chrome.action.onClicked.addListener(async (tab) => {
const { count } = await chrome.storage.local.get('count');
await chrome.storage.local.set({ count: count + 1 });
// Send message to content script
await chrome.tabs.sendMessage(tab.id!, { type: 'INCREMENT', count: count + 1 });
});
Content script
// content/content-script.ts
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'INCREMENT') {
console.log('Count:', message.count);
sendResponse({ ok: true });
}
});
Content scripts run in an isolated world — they can access the DOM but not page JavaScript variables. For two-way communication between the page JS and extension, use window.postMessage.
Popup with React
// popup/popup.tsx
import { useEffect, useState } from 'react';
export default function Popup() {
const [count, setCount] = useState(0);
useEffect(() => {
chrome.storage.local.get('count').then(({ count }) => setCount(count ?? 0));
}, []);
return (
<div style={{ width: 200, padding: 16 }}>
<p>Count: {count}</p>
<button onClick={() => chrome.action.setBadgeText({ text: String(count) })}>
Show badge
</button>
</div>
);
}
Permission comparison
| Permission |
What it allows |
Scrutiny level |
storage |
Read/write extension local storage |
Low |
activeTab |
Access the current tab on user gesture |
Low |
tabs |
Access all tab URLs and titles |
High |
<all_urls> host permission |
Run content scripts everywhere |
Very high |
webRequest |
Observe (not block) network requests |
High |
declarativeNetRequest |
Block/redirect requests by rules |
Medium |
Request the minimum. Use activeTab + host_permissions for specific domains instead of <all_urls>.
How to pick your extension type
- Action popup + storage? → Simplest shape; good for productivity tools that act on the current page.
- Persistent sidebar? → Use the Side Panel API (
chrome.sidePanel).
- Content transformation (ad block, page modification)? → Content script +
declarativeNetRequest.
- Background data sync? → Service worker with
chrome.alarms for periodic wakeup.
Common mistakes
Storing state in service worker variables. The worker terminates; variables are lost. Always use chrome.storage.local or chrome.storage.session.
Using fetch in content scripts cross-origin. Content scripts share the page origin; cross-origin requests need to go through the service worker via chrome.runtime.sendMessage.
Not handling the service worker dormancy. If your popup sends a message to the service worker and it has terminated, the message fails. Wrap in a retry or use chrome.runtime.getBackgroundPage wakeup pattern.
Over-requesting permissions at install. Use chrome.permissions.request to ask for optional permissions at the moment the user needs them.
What to skip
- Persistent background page patterns from MV2. There is no persistent background in MV3; architect around service worker termination from the start.
eval() in extension code. The Content Security Policy for MV3 disallows it. Use compiled code.
- Publishing to the Chrome Web Store without a privacy policy. Extensions that access any user data require a linked privacy policy.
FAQ
Does Firefox support MV3?
Yes — Firefox supports MV3, but with some API differences (e.g., browser. namespace vs chrome.). Use webextension-polyfill for cross-browser support.
How long does Chrome Web Store review take?
Typically 1–3 business days for new submissions; updates are often auto-approved in hours if the permissions do not change.
Can I use TypeScript and React in an extension?
Yes — the Vite + CRXJS setup above supports both. The popup, options page, and content scripts can all be TypeScript/React files.
How do I debug a service worker?
Go to chrome://extensions, find your extension, and click "Service Worker" to open DevTools for the service worker context.
Where to go next