Shipping One Manifest for Chrome and Firefox

Generate per-engine manifests from a single source: background service_worker vs scripts, browser_specific_settings, key differences, and a build step that fails on drift.

Published August 1, 2026 Updated August 1, 2026 8 min read
Table of Contents

Two manifests in the repository start identical and diverge within three releases: a permission is added to one, a content script glob is fixed in the other, and the Firefox build ships with a rule nobody remembers removing. The manifest is configuration, and duplicated configuration always drifts. This page is part of Cross-Browser API Compatibility and builds one source of truth that emits a correct manifest for each engine, with a check that fails the build when the two go out of step for a reason nobody declared.

Root cause: the engines disagree on a handful of keys, not on the file

Ninety percent of a Manifest V3 file is identical across Chrome, Firefox and Safari. The disagreements are concentrated in five places, and every one of them is a key that either must differ or must be absent.

The manifest keys that cannot be sharedHow each engine expects the background declaration, add-on identity, packaging key, and side panel surface to be declared.Manifest keyChrome / EdgeFirefoxSafaribackgroundservice_workerscripts arrayservice_workerbrowser_specific_settingsMust be absentRequired for AMOIgnoredkey (ID pinning)Dev onlyIgnoredIgnoredside_panelSupportedsidebar_actionNot availableoauth2getAuthToken onlyIgnoredIgnoredminimum versionminimum_chrome_versionstrict_min_versionFrom the app
Everything not in this table is genuinely shared — which is why generating beats duplicating.

Step 1. Write the shared manifest as data, not JSON

A JavaScript module can be composed and checked; a JSON file can only be copied.

 1// build/manifest.base.js
 2export const base = {
 3  manifest_version: 3,
 4  name: "__MSG_extensionName__",
 5  description: "__MSG_extensionDescription__",
 6  default_locale: "en",
 7  version: process.env.npm_package_version,
 8  icons: { 16: "icons/16.png", 48: "icons/48.png", 128: "icons/128.png" },
 9  action: { default_popup: "popup.html", default_title: "__MSG_actionTitle__" },
10  options_ui: { page: "options.html", open_in_tab: true },
11  permissions: ["storage", "scripting", "tabs", "alarms"],
12  host_permissions: ["https://*/*"],
13  content_scripts: [{ matches: ["https://*/*"], js: ["content.js"], run_at: "document_idle" }],
14};

Execution context: Node, at build time — never shipped. Reading the version from npm_package_version removes the second-most-common drift after permissions: a version bumped in package.json and forgotten in the manifest. Everything here is accepted verbatim by all three engines, which is the test for whether a key belongs in the base rather than in an overlay.

Step 2. Express each engine as an overlay

 1// build/manifest.targets.js
 2export const targets = {
 3  chrome: (m) => ({
 4    ...m,
 5    background: { service_worker: "sw.js", type: "module" },
 6    side_panel: { default_path: "panel.html" },
 7    minimum_chrome_version: "116",
 8    permissions: [...m.permissions, "sidePanel"],
 9  }),
10
11  firefox: (m) => ({
12    ...m,
13    background: { scripts: ["sw.js"], type: "module" },      // Firefox MV3 uses an event page
14    sidebar_action: { default_panel: "panel.html", default_title: "__MSG_panelTitle__" },
15    browser_specific_settings: {
16      gecko: { id: "tab-curator@example.com", strict_min_version: "121.0" },
17    },
18  }),
19
20  safari: (m) => ({
21    ...m,
22    background: { service_worker: "sw.js", type: "module" },
23    // No side panel surface; the popup carries the same content.
24  }),
25};

Execution context: Node, at build time. The background declaration is the one that catches everyone: Firefox’s Manifest V3 implementation uses a non-persistent event page declared with scripts, not a service_worker key, and a manifest with the Chrome form is rejected at install with a validation error rather than a warning. Requesting the sidePanel permission only in the Chrome overlay avoids an unknown-permission warning in Firefox’s linter. Safari accepts the Chrome shape but has no side panel surface at all, so the overlay simply omits it.

Step 3. Emit and validate in one step

One source, three artefacts, one gateThe base object is merged with each engine overlay, written to a per-target build directory, and checked against a declared list of allowed differences.manifest.base.jsthe shared 90%targets[engine]declared overlaymerge, then assertdist/chrome/manifest.jsonservice_workerdist/firefox/manifest.jsonscripts + gecko iddist/safari/manifest.jsonno panel surface
The drift check is what makes this better than three files — an undeclared difference fails the build.
 1// build/manifest.mjs
 2import { writeFileSync, mkdirSync } from "node:fs";
 3import { base } from "./manifest.base.js";
 4import { targets } from "./manifest.targets.js";
 5
 6// Keys that are ALLOWED to differ. Anything else diverging is a mistake.
 7const ALLOWED = new Set([
 8  "background", "side_panel", "sidebar_action",
 9  "browser_specific_settings", "minimum_chrome_version", "permissions", "key",
10]);
11
12const built = Object.fromEntries(
13  Object.entries(targets).map(([name, overlay]) => [name, overlay(structuredClone(base))]),
14);
15
16const [reference, ...others] = Object.values(built);
17for (const other of others) {
18  for (const key of new Set([...Object.keys(reference), ...Object.keys(other)])) {
19    if (ALLOWED.has(key)) continue;
20    if (JSON.stringify(reference[key]) !== JSON.stringify(other[key])) {
21      throw new Error(`manifest drift on "${key}" — add it to ALLOWED or fix the overlay`);
22    }
23  }
24}
25
26for (const [name, manifest] of Object.entries(built)) {
27  mkdirSync(`dist/${name}`, { recursive: true });
28  writeFileSync(`dist/${name}/manifest.json`, JSON.stringify(manifest, null, 2) + "\n");
29}

Execution context: Node, invoked from an npm script before packaging. The ALLOWED set is the point of the whole exercise: it turns “these files differ” from an invisible fact into a declaration somebody had to write down. A new permission added to one overlay by accident fails the build with the key name in the message. Wire this into the release pipeline described in building a GitHub Actions pipeline for extensions so it runs before anything is uploaded.

Step 4. Keep the code single-source too

A generated manifest is only half the job if sw.js still branches on the engine. Let the capability probe do it.

1// sw.js — the same file ships to all three builds
2import { can } from "./capabilities.js";
3
4chrome.action.onClicked.addListener(async (tab) => {
5  if (can.sidePanel) return chrome.sidePanel.open({ tabId: tab.id });
6  if (typeof browser !== "undefined" && browser.sidebarAction) return browser.sidebarAction.open();
7  return chrome.windows.create({ url: "/panel.html", type: "popup", width: 420, height: 640 });
8});

Execution context: Service worker in Chrome and Safari, event page in Firefox — the same file works in both because the difference is in how the browser loads it, not in what it contains. Registering the listener at the top level is required in all three. The probe pattern is covered in feature detection instead of browser sniffing, and using it here means one bundle rather than three.

Step 5. Lint each artefact with the engine’s own tool

Each store runs a validator you can run first.

Where each validator belongs in the release pipelineManifest generation and drift checking run first, then the per-engine linters, then packaging and upload.git pushuploadedGenerate …~1 s, no b…web-ext l…AMO's own …Unit testsno browser neededBrowser E2EChromium + FirefoxPackage + uploadthree storesfails here for freethe expensive stage
The cheapest checks run first, so a drifted manifest never reaches a browser download.
1# Firefox: the official linter, the same one AMO runs on upload
2npx web-ext lint --source-dir dist/firefox
3
4# Chrome: pack and check the manifest parses with the target minimum version
5npx chrome-webstore-upload-cli@3 --source dist/chrome --dry-run
6
7# Safari: the converter reports unsupported keys as warnings
8xcrun safari-web-extension-converter dist/safari --no-open --force

Execution context: A developer machine or CI runner. web-ext lint catches unknown permissions, an invalid gecko.id and the service_worker-instead-of-scripts mistake before an upload is rejected. The Safari converter is macOS-only, so gate that line behind a platform check in CI; what it reports is covered in handling Safari web extension conversion gaps.

Cross-browser variation

  • Chrome/Edge: service_worker with type: "module" is the only accepted background form. browser_specific_settings must not be present — Chrome rejects unknown top-level keys during store review even though it tolerates them locally.
  • Firefox: Manifest V3 background is scripts with an optional type: "module". browser_specific_settings.gecko.id is required for AMO signing, and strict_min_version controls which users receive the build.
  • Safari: accepts the Chrome-shaped manifest. Version floors come from the containing app’s deployment target rather than a manifest key.
  • All engines: the version string must be one to four dot-separated integers. Use version_name for anything human-readable such as a beta suffix.

Verification

  1. Run the build and diff dist/chrome/manifest.json against dist/firefox/manifest.json. Every difference should correspond to a key in ALLOWED.
  2. Add a permission to one overlay only and confirm the build fails with that key named.
  3. Load dist/firefox with web-ext run and confirm the event page starts.
  4. Load dist/chrome unpacked and confirm the side panel opens from the action button.
  5. Bump the version in package.json alone and confirm all three manifests pick it up.

FAQ

Can I just use one manifest with extra keys and let each engine ignore what it does not know?

Locally, yes. In review, no — both Chrome and AMO flag unknown keys, and Firefox rejects the service_worker background outright.

Does Firefox support service workers as the background context yet?

Its Manifest V3 background is a non-persistent event page. Write your code as if it can be evicted at any moment either way, which is the assumption the service worker fundamentals guide starts from.

Should the key field be in the shipped Chrome manifest?

No. It pins the extension ID for local development so OAuth redirect URLs stay stable; the store assigns the ID itself. Add it in a dev-only overlay.

What about three separate bundles of code?

Avoid it. One bundle plus capability probes is smaller to maintain and means a bug fixed once is fixed everywhere.

Other Core APIs & Cross-Browser Data Management Resources