Shipping one Manifest V3 extension to Chrome, Edge, and Firefox from a single source
I recently shipped a small Manifest V3 browser extension - a toolbar popup plus an address-bar (omnibox) search, zero permissions - and wanted it on all three major stores: the Chrome Web Store, Edge Add-ons, and Firefox's AMO. The pitch for MV3 is that it's the shared standard, so "write once, ship everywhere." That's mostly true - but the small divergences are exactly the kind of thing that fails a store review at 11pm. Here's the complete list of what actually differs, plus a tiny build script that emits all three packages from one source.
Chrome and Edge are the same package
Good news first: Edge Add-ons accepts the exact same MV3 zip as the Chrome Web Store. Edge is Chromium, background.service_worker works as-is, and the chrome.* APIs are identical. You upload the same artifact to both. (I keep them as separately-named zips only for a clean per-store upload trail.)
Firefox needs three manifest tweaks - and zero code changes
Firefox is where it gets interesting. The code didn't change at all - the extension uses only chrome.omnibox, chrome.runtime, and chrome.tabs, all of which Firefox exposes via the chrome.* alias. Only the manifest needs three edits.
1. background.service_worker โ background.scripts
Firefox's MV3 support prefers an event page over a service worker for the widest compatibility. If your background script only registers listeners at the top level (no service-worker-only globals), it runs fine as an event-page script:
// Chrome / Edge
"background": {
"service_worker": "background.js"
}
// Firefox
"background": {
"scripts": ["background.js"]
}
2. browser_specific_settings.gecko.id
AMO requires an explicit extension id. Chrome/Edge derive one for you; Firefox wants it declared:
"browser_specific_settings": {
"gecko": {
"id": "your-ext@yourdomain.com"
}
}
3. data_collection_permissions - and the version floor it drags in
This is the one that surprised me. Newer Firefox requires every extension to explicitly declare what user data it collects - even when the answer is "nothing." Omit it and web-ext lint fails with MISSING_DATA_COLLECTION_PERMISSIONS:
"gecko": {
"id": "your-ext@yourdomain.com",
"strict_min_version": "142.0",
"data_collection_permissions": {
"required": ["none"]
}
}
The gotcha is the version floor. data_collection_permissions is only supported from Firefox 140 on desktop and 142 on Android. Set strict_min_version below 142 and the linter throws KEY_FIREFOX_UNSUPPORTED_BY_MIN_VERSION - first for desktop, then again for Android. 142.0 is the floor that satisfies both.
The omnibox trap on Firefox for Android
If your extension has an omnibox keyword like mine, do not tick "Firefox for Android" compatibility on AMO. The omnibox API is not supported on Firefox for Android - the popup still works as an overlay, but the address-bar keyword silently does nothing. Ship desktop-only until you've adapted and tested for mobile, or you'll be shipping a broken core feature to Android users.
One source, three zips
Rather than maintain three manifests by hand, keep one and transform it at build time. The whole script is ~40 lines; the interesting part is the Firefox transform:
function firefoxManifest(m) {
const fx = structuredClone(m);
fx.background = { scripts: ["background.js"] };
fx.browser_specific_settings = {
gecko: {
id: "your-ext@yourdomain.com",
strict_min_version: "142.0",
data_collection_permissions: {
required: ["none"]
},
},
};
return fx;
}
// pack("chrome", base);
// pack("edge", base); // === Chrome, kept separate for a clean upload trail
// pack("firefox", firefoxManifest(base));
Each pack() stages the shared files (popup.*, background.js, icons, plus the per-store manifest) and zips them with paths at the archive root (manifest.json must sit at the top level, not under a subfolder).
Validate before you upload
Run web-ext lint - the same validator AMO runs - on the Firefox package before submitting. It catches the version-floor and data-collection issues above locally, instead of after a rejected upload. My bar was 0 errors / 0 warnings / 0 notices before the zip went anywhere near the store.
The short version
- Chrome == Edge - one MV3 zip, upload to both.
- Firefox - three manifest lines (
background.scripts,gecko.id,data_collection_permissions) and one version floor (142.0). No code changes. - Omnibox on Android - unsupported; don't tick the box.
- Lint locally with
web-extso the store never says no.
If you're maintaining separate repos or manifests per browser, collapsing to one source + a build transform is an afternoon well spent.
Comments
No comments yet. Start the discussion.