Cross-browser Extension Development (WebExtension API)
WebExtension API is a standard supported by Chrome, Firefox, Edge, Opera, and (partially) Safari. A single codebase with conditional patches for browser differences is the right approach to cross-browser development. Copying and maintaining separate versions for each browser is an anti-pattern.
API compatibility by browser
| API | Chrome | Firefox | Edge | Opera | Safari |
|---|---|---|---|---|---|
storage.local |
MV2/3 | MV2/3 | MV2/3 | MV2/3 | 14+ |
storage.sync |
✓ | ✓ | ✓ | ✓ | 15+ |
tabs |
✓ | ✓ | ✓ | ✓ | 14+ |
scripting (MV3) |
✓ | 101+ | ✓ | 92+ | 15.4+ |
declarativeNetRequest |
✓ | 113+ | ✓ | 92+ | 15.4+ |
sidePanel |
114+ | — | — | — | — |
webRequest |
MV2 only | ✓ | MV2 only | MV2 only | — |
Polyfill: webextension-polyfill
Mozilla developed webextension-polyfill — it converts chrome.* (Callback) to browser.* (Promise) and smooths differences between browsers:
npm install webextension-polyfill
import browser from 'webextension-polyfill';
// Same code for Chrome and Firefox
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
await browser.storage.local.set({ data: 'value' });
const result = await browser.storage.local.get('data');
Single manifest: MV2 vs MV3
Maintaining two manifests in parallel is standard practice for maximum coverage:
// scripts/build.js
const fs = require('fs');
const target = process.env.TARGET_BROWSER; // 'chrome', 'firefox', 'safari'
const manifests = {
chrome: {
manifest_version: 3,
background: { service_worker: 'background.js' },
action: { default_popup: 'popup.html' },
},
firefox: {
manifest_version: 2,
background: { scripts: ['background.js'], persistent: false },
browser_action: { default_popup: 'popup.html' },
browser_specific_settings: {
gecko: { id: '[email protected]', strict_min_version: '109.0' },
},
},
safari: {
manifest_version: 3,
background: { service_worker: 'background.js' },
action: { default_popup: 'popup.html' },
browser_specific_settings: {
safari: { strict_min_version: '15.4' },
},
},
};
const base = JSON.parse(fs.readFileSync('./manifest.base.json', 'utf8'));
const merged = { ...base, ...manifests[target] };
fs.writeFileSync('./dist/manifest.json', JSON.stringify(merged, null, 2));
Project structure
my-extension/
├── src/
│ ├── background/
│ │ ├── index.ts
│ │ └── handlers/
│ ├── content/
│ │ ├── index.ts
│ │ └── modules/
│ ├── popup/
│ │ ├── App.tsx
│ │ └── index.tsx
│ ├── options/
│ │ └── App.tsx
│ └── shared/
│ ├── storage.ts # wrapper over browser.storage
│ ├── messaging.ts # typed messages
│ └── browser.ts # current browser detection
├── manifest.base.json
├── manifests/
│ ├── mv2.json
│ └── mv3.json
├── vite.config.ts
└── package.json
Typed messages
Strict message typing between contexts prevents silent bugs:
// shared/messages.ts
export type Message =
| { type: 'GET_PAGE_DATA'; url: string }
| { type: 'SET_BADGE'; count: number; color?: string }
| { type: 'OPEN_OPTIONS' }
| { type: 'EXTRACT_TEXT'; selector: string };
export type MessageResponse<T extends Message> =
T extends { type: 'GET_PAGE_DATA' } ? { title: string; meta: Record<string, string> } :
T extends { type: 'EXTRACT_TEXT' } ? { text: string } :
void;
// Typed sendMessage
export async function sendMessage<T extends Message>(
message: T
): Promise<MessageResponse<T>> {
return browser.runtime.sendMessage(message) as Promise<MessageResponse<T>>;
}
// Typed onMessage
export function onMessage<T extends Message['type']>(
type: T,
handler: (msg: Extract<Message, { type: T }>, sender: browser.runtime.MessageSender) => Promise<MessageResponse<Extract<Message, { type: T }>>>
) {
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === type) {
handler(msg, sender).then(sendResponse);
return true;
}
});
}
Browser detection and conditional paths
// shared/browser.ts
export type BrowserName = 'chrome' | 'firefox' | 'safari' | 'edge' | 'opera';
export function detectBrowser(): BrowserName {
// @ts-ignore
if (typeof browser !== 'undefined' && browser.runtime?.id) {
const ua = navigator.userAgent;
if (ua.includes('Firefox/')) return 'firefox';
if (ua.includes('OPR/')) return 'opera';
if (ua.includes('Edg/')) return 'edge';
// Safari determined via manifest
if (!ua.includes('Chrome')) return 'safari';
}
return 'chrome';
}
export const IS_FIREFOX = detectBrowser() === 'firefox';
export const IS_SAFARI = detectBrowser() === 'safari';
export const SUPPORTS_SIDE_PANEL = detectBrowser() === 'chrome';
Building for multiple browsers with Vite
// vite.config.ts
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
const browser = process.env.TARGET_BROWSER ?? 'chrome';
const manifestFile = browser === 'firefox'
? './manifests/mv2.json'
: './manifests/mv3.json';
export default defineConfig({
define: {
__BROWSER__: JSON.stringify(browser),
__IS_FIREFOX__: browser === 'firefox',
},
plugins: [crx({ manifest: require(manifestFile) })],
build: {
outDir: `dist/${browser}`,
},
});
// package.json scripts
{
"build:chrome": "TARGET_BROWSER=chrome vite build",
"build:firefox": "TARGET_BROWSER=firefox vite build",
"build:edge": "TARGET_BROWSER=edge vite build",
"build:safari": "TARGET_BROWSER=safari vite build",
"build:all": "npm run build:chrome && npm run build:firefox && npm run build:edge"
}
CI/CD: build and publish all versions
# .github/workflows/publish.yml
name: Publish Extensions
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build:all
- name: Publish to Chrome Web Store
uses: trstringer/manual-approval@v1
env:
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
- name: Sign & Publish to Firefox AMO
run: |
npx web-ext sign \
--source-dir dist/firefox \
--api-key ${{ secrets.AMO_JWT_ISSUER }} \
--api-secret ${{ secrets.AMO_JWT_SECRET }} \
--channel listed
Testing cross-browser extension
// tests/messaging.test.ts (with @jest-environment jsdom)
import { sendMessage, onMessage } from '../src/shared/messaging';
// Mock browser.runtime
global.chrome = {
runtime: {
sendMessage: jest.fn((msg, cb) => cb({ data: 'test' })),
onMessage: { addListener: jest.fn() },
},
} as any;
test('sendMessage GET_PAGE_DATA returns data', async () => {
const result = await sendMessage({ type: 'GET_PAGE_DATA', url: 'https://example.com' });
expect(result).toHaveProperty('title');
});
For E2E — playwright + official extension running example:
const { chromium } = require('playwright');
const path = require('path');
const browser = await chromium.launchPersistentContext('', {
headless: false,
args: [
`--disable-extensions-except=${path.resolve('./dist/chrome')}`,
`--load-extension=${path.resolve('./dist/chrome')}`,
],
});
Timeline
Cross-browser extension (Chrome + Firefox + Edge) with single codebase, typing, auto-build, and publishing to three stores — 10–16 working days depending on functionality. Adding Safari (Xcode wrapper + App Store) adds 5–8 days. Store review timelines — separate and in parallel.







