· 5 min read

Writing a Browser Extension That Works on Chrome, Firefox, and Safari

Kief Studio
Writing a Browser Extension That Works on Chrome, Firefox, and Safari

Writing a Browser Extension That Works on Chrome, Firefox, and Safari

You want to ship one extension to three browsers. You've read that WebExtensions is a "standard." You've been lied to.

Chrome, Firefox, and Safari all claim WebExtensions compatibility. In practice, you're dealing with three different namespace conventions, two manifest versions, incompatible sidebar APIs, and Safari demanding you wrap everything in an Xcode project. It's harder than cross-platform mobile development, and nobody warns you until you're knee-deep in polyfills.

Here's how to actually do it in 2026 without losing your mind.

The Manifest V3 Landscape Right Now

Chrome killed Manifest V2 permanently in July 2025. By August, 73.4% of Chrome extensions had migrated. The other 26%? Gone. The Chrome Web Store dropped from 137,000+ extensions to roughly 112,000.

Firefox took a different path. They support MV3 but kept webRequest blocking intact. Safari went with Event Pages instead of Service Workers. Both browsers explicitly rejected Chrome's service worker mandate because it breaks too many use cases.

The result: you can specify both service_worker AND scripts in your manifest background field, and it works across Chrome 121+ and Firefox 121+.

{
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"scripts": ["background.js"],
"type": "module"
}
}

Chrome reads service_worker. Firefox reads scripts. Same file, both work. Safari uses Event Pages by default when you convert with xcrun safari-web-extension-converter.

The Namespace Problem

Chrome uses chrome.* with callbacks. Firefox uses browser.* with promises. The WebExtension polyfill from Mozilla bridges this gap:

npm install webextension-polyfill
import browser from 'webextension-polyfill';

// Works everywhere -- returns a promise
const tabs = await browser.tabs.query({ active: true, currentWindow: true });

But the polyfill doesn't cover everything. sidePanel (Chrome) vs sidebarAction (Firefox) is a completely separate API. Chrome invented sidePanel instead of adopting Firefox's existing sidebarAction. You either maintain two implementations or pick one browser to ignore.

Use WXT. Seriously.

Rolling your own webpack/vite config for cross-browser builds is a solved problem. WXT (about 9,200 GitHub stars, actively maintained) handles it:

npx wxt@latest init my-extension
cd my-extension
npm install

WXT gives you:

  • Single codebase builds for Chrome MV3, Firefox MV2/MV3, Safari, Edge, Opera
  • HMR that works on service workers (not just content scripts)
  • Framework-agnostic: React, Vue, Svelte, Solid, or vanilla
  • Auto-imports for the browser API with correct typing

Your project structure looks like this:

my-extension/
entrypoints/
background.ts # service worker / event page
content.ts # content script
popup/
index.html
main.tsx
public/
icon-128.png
wxt.config.ts

A content script in WXT:

// entrypoints/content.ts
export default defineContentScript({
matches: ['*://*.github.com/*'],
main() {
console.log('Running on GitHub');
// Your DOM manipulation here
},
});

Build for all targets:

npx wxt build # Chrome MV3
npx wxt build --browser firefox # Firefox MV3
npx wxt build --browser safari # Safari (needs xcrun conversion after)

Plasmo had more GitHub stars (~12k) but entered maintenance mode. WXT is where active development is happening.

Safari: The Xcode Tax

Every Safari extension ships inside a native macOS/iOS app. There's no way around this. After building your extension:

xcrun safari-web-extension-converter ./dist/safari \
--project-location ./safari-project \
--app-name "My Extension" \
--bundle-identifier
com.yourcompany.myextension

This generates an Xcode project. You open it, sign it with your Apple Developer account ($99/year), and submit to the App Store.

The upside: Safari gives you native messaging for free. Your extension can talk to its host app with zero extra configuration. Chrome and Firefox developers have to implement the entire native messaging host protocol themselves -- a separate binary, a JSON manifest in a specific OS location, stdin/stdout JSON serialization.

The downside: you need a Mac to build, test, and submit. There's no CI shortcut that avoids Xcode.

The Permission Model Divergence

MV3 changed how permissions work, but each browser interprets it differently.

Chrome requires host_permissions separate from permissions:

{
"permissions": ["storage", "activeTab"],
"host_permissions": ["https://*.github.com/*"]
}

Firefox accepts this format but also still supports the MV2 style of putting hosts in permissions. Safari is the strictest -- it defaults to asking the user per-site even if you declare host permissions.

For content scripts, declare your matches explicitly. activeTab only activates on user click -- it doesn't give you persistent access.

Storage: The One API That Actually Works Cross-Browser

browser.storage.local and browser.storage.sync work identically across all three browsers. This is your safe bet for persisting data:

// Save
await browser.storage.local.set({
settings: { theme: 'dark', scanOnLoad: true }
});

// Load
const { settings } = await browser.storage.local.get('settings');

storage.sync syncs across devices on Chrome (via Google account) and Firefox (via Firefox Sync). Safari doesn't sync extension storage -- it just falls back to local. Handle this gracefully:

const storage = browser.storage.sync || browser.storage.local;

Message Passing Between Contexts

Your extension has at least three isolated contexts: the service worker (background), content scripts (page context), and the popup UI. They can't share variables. Everything goes through message passing:

// From content script to background
const response = await browser.runtime.sendMessage({
type: 'SCAN_DEPENDENCIES',
payload: { lockfileUrl: window.location.href }
});

// In background service worker
browser.runtime.onMessage.addListener((message, sender) => {
if (message.type === 'SCAN_DEPENDENCIES') {
return scanLockfile(message.payload.lockfileUrl);
}
});

Return a promise from the listener and the sender gets the resolved value. This works everywhere. Where it breaks: long-lived connections via browser.runtime.connect() behave slightly differently in Safari. For most extensions, one-shot messages are enough.

Testing Across Browsers Without Losing Your Day

You don't need three separate dev environments. WXT's dev mode launches an isolated browser profile:

npx wxt dev # Chrome with fresh profile
npx wxt dev --browser firefox # Firefox with fresh profile

For Safari, load the unsigned extension from Xcode's "Develop" menu during development. Enable "Allow Unsigned Extensions" in Safari's Developer settings.

Automated testing is harder. Playwright supports Chrome extensions via persistent contexts:

const context = await chromium.launchPersistentContext('', {
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});

Firefox and Safari don't have equivalent Playwright support for extension testing. For those, you're doing manual QA or writing integration tests against your extension's internal modules (which don't touch browser APIs).

Store Submission: Three Different Processes

Chrome Web Store: $5 one-time fee. Upload a zip. Review takes 1-3 days. Automated scanning catches most issues fast.

Firefox Add-ons (AMO): Free. Upload a zip plus source code if you use a bundler. Review is faster than Chrome -- often same-day for updates. Firefox also supports self-distribution (signed .xpi files hosted on your own domain).

App Store (Safari): $99/year Apple Developer Program. Submit through Xcode/App Store Connect. Review takes 1-7 days. You're submitting a native app that happens to contain an extension.

The Business Case for Going Cross-Browser

60% of Chrome extensions haven't been updated in 12 months. The MV2 purge killed thousands more. There's a land-grab happening: rebuild useful abandoned extensions on MV3, ship them cross-browser, and you're immediately in a less competitive market.

Safari has 14.75% global browser share (22.89% on mobile) with almost zero extension competition. Google doesn't support extensions on mobile Chrome at all. If your extension solves a real problem, the Safari App Store is wide open.

Browser extension businesses average 83% profit margins. CSS Scan made $70K+ in 9 months from 50 hours of development. Easy Folders earns $3,700/month organizing AI chat history. The economics work because distribution is built into the browser and there's no infrastructure to maintain.

Vekt's Cross-Browser Approach

We built the Vekt browser extension to show trust badges on package registry pages -- npm, PyPI, crates.io, and others. 12.8KB content script, ships on Chrome MV3, Firefox, and Safari from a single codebase.

The content script pattern is straightforward: match on registry URLs, query the Vekt API for vulnerability data, inject badge elements into the DOM. The same code runs on all three browsers because content scripts are the most compatible layer of the extension API. The differences live in the background worker and permission handling -- exactly the parts WXT abstracts away.

What To Build First

Start with a content script that modifies pages. It's the most portable extension type. Background-only extensions and those needing sidePanel/sidebarAction hit cross-browser friction immediately.

Use WXT from day one. The 10 minutes you spend on setup saves you from debugging three different build configurations later.

Ship to Chrome first (largest market), Firefox second (easiest submission, most dev-friendly), Safari third (requires Xcode but least competition). And look at what got abandoned in the MV2 purge. Some developer's loss is your opportunity.