Generating a Manifest per Browser Target

Produce Chrome, Firefox and Safari manifests from one typed source — background keys, gecko ids, permission differences, update_url for self-hosting, and validating each output.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

A hand-maintained manifest.json works for exactly one browser. The moment a Firefox build exists, someone keeps a second copy, and within a release the two disagree about a permission. The manifests genuinely must differ in a handful of places — the background declaration, Firefox’s add-on id, a few Chrome-only permissions — and everywhere else they must be identical. Generating them from one source is how you get both properties at once. This guide is part of build tooling and bundlers.

Where the targets actually differ

Manifest keys that differ by targetBackground declaration, extension id, Chrome-only permissions, side panel and sidebar keys, update_url and minimum version keys compared across Chrome, Firefox and Safari manifests.KeyChrome / EdgeFirefoxSafaribackgroundservice_workerscriptsservice_workerExtension idkey (optional)browser_specific_settings…Xcode bundle idoffscreen, sidePanel permsYesOmitOmitside_panel / sidebar_actionside_panelsidebar_actionNeitherupdate_urlSelf-hosted onlySelf-hosted onlyNeverMinimum versionminimum_chrome_versiongecko.strict_min_versionIn Xcode
Six keys differ; everything else — name, version, content scripts, icons — should come from one place.

Step-by-step

1. Write the manifest as code

 1// build/manifest.mjs
 2import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
 3
 4const pkg = JSON.parse(readFileSync("package.json", "utf8"));
 5
 6const base = {
 7  manifest_version: 3,
 8  name: "__MSG_extName__",
 9  description: "__MSG_extDescription__",
10  default_locale: "en",
11  version: pkg.version,
12  icons: { 16: "icons/16.png", 48: "icons/48.png", 128: "icons/128.png" },
13  permissions: ["storage", "alarms", "contextMenus"],
14  optional_host_permissions: ["https://*/*"],
15  action: { default_popup: "popup.html" },
16  options_ui: { page: "options.html", open_in_tab: true },
17  content_scripts: [{ matches: ["https://*.example.com/*"], js: ["content/main.js"] }],
18};

Execution context: Node, during the build. Reading version from package.json means there is exactly one version number in the repository — important when error reports are bucketed by version and when stores reject a version that did not increase.

2. Apply per-target differences as small, explicit functions

 1const TARGETS = {
 2  chrome: (m) => ({
 3    ...m,
 4    minimum_chrome_version: "116",
 5    permissions: [...m.permissions, "offscreen", "sidePanel"],
 6    background: { service_worker: "service-worker.js", type: "module" },
 7    side_panel: { default_path: "panel.html" },
 8  }),
 9  firefox: (m) => ({
10    ...m,
11    background: { scripts: ["service-worker.js"], type: "module" },
12    sidebar_action: { default_panel: "panel.html", default_title: "__MSG_extName__" },
13    browser_specific_settings: { gecko: { id: "reader@example.com", strict_min_version: "121.0" } },
14  }),
15  safari: (m) => ({
16    ...m,
17    background: { service_worker: "service-worker.js", type: "module" },
18  }),
19};

Execution context: Node. Each target function is a readable diff against the base; reviewing it answers “how does the Firefox build differ?” in one screen. The capability side of the same differences is in building a capability matrix for your extension.

3. Write it into each target’s output directory

1const target = process.argv[2];
2if (!TARGETS[target]) throw new Error(`unknown target ${target}`);
3
4const manifest = TARGETS[target](base);
5mkdirSync(`dist/${target}`, { recursive: true });
6writeFileSync(`dist/${target}/manifest.json`, JSON.stringify(manifest, null, 2));
7console.log(`manifest → dist/${target}/manifest.json (v${manifest.version})`);

Execution context: Node, invoked as node build/manifest.mjs firefox after the bundler has written that target’s code. Failing on an unknown target is worth the line — a typo otherwise produces a Chrome manifest in a directory named after the typo.

4. Add a self-hosted variant without polluting the store builds

update_url is required for self-hosting and rejected by every store. Treat it as a separate target that extends Chrome’s.

1TARGETS["chrome-enterprise"] = (m) => ({
2  ...TARGETS.chrome(m),
3  update_url: "https://ext.example.com/updates.xml",
4});

Execution context: Node. A distinct target name means a store upload can never accidentally carry the key — the distribution model is covered in publishing private and enterprise extensions.

5. Validate each manifest against what the build produced

A generated manifest can still name a file the bundler did not emit. Check every path.

 1function referencedFiles(m) {
 2  return [
 3    m.background?.service_worker, ...(m.background?.scripts ?? []),
 4    m.action?.default_popup, m.options_ui?.page, m.side_panel?.default_path,
 5    m.sidebar_action?.default_panel,
 6    ...Object.values(m.icons ?? {}),
 7    ...(m.content_scripts ?? []).flatMap((c) => [...(c.js ?? []), ...(c.css ?? [])]),
 8  ].filter(Boolean);
 9}
10
11const missing = referencedFiles(manifest).filter((f) => !existsSync(`dist/${target}/${f}`));
12if (missing.length) throw new Error(`manifest references missing files: ${missing.join(", ")}`);

Execution context: Node, at the end of the manifest step. This converts “could not load manifest” at install time — which on Firefox arrives with an unhelpful message — into a failed build that names the file.

6. Lint each target with the browser’s own tools

1npx web-ext lint --source-dir dist/firefox
2# Chrome has no official linter; load it unpacked in CI with Playwright instead.

Execution context: your shell or CI. web-ext lint checks the manifest against Firefox’s schema and catches Chrome-only keys that slipped into the Firefox build. The Playwright approach for Chrome is in loading an unpacked extension in Playwright.

From one source to three packagesA base manifest object is transformed by a per-target function, written into that target's output directory, checked against the emitted files and linted.base manifestshared keys + versionTARGETS[target]small explicit diffdist/<target>/manifest.jsonwritten after bundlingthen prove it matches the outputReferenced files existevery pathweb-ext lintFirefox schemaPackagezip per target
The validation step is what makes the generator trustworthy — it proves the manifest and the bundle agree.

Keeping the targets from drifting apart

A per-target function is a permanent invitation to add one more difference, and each one is a place the builds can diverge in behaviour. Two practices keep the list short.

Differences must be justified by the platform, not by convenience. A key belongs in a target function only if the other engines reject it or lack the feature. A permission added to Chrome “because we only tested there” is a bug report from a Firefox user waiting to happen.

Diff the outputs in CI. Print the keys that differ between targets on every build; a new line in that diff is a change someone should notice in review.

 1const a = TARGETS.chrome(base), b = TARGETS.firefox(base);
 2const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
 3for (const k of keys) {
 4  if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) console.log(`differs: ${k}`);
 5}
 6// differs: minimum_chrome_version
 7// differs: permissions
 8// differs: background
 9// differs: side_panel
10// differs: sidebar_action
11// differs: browser_specific_settings

Execution context: Node, as a CI step. Committing the expected output as a snapshot turns an unintended new difference into a failing test rather than something discovered by users of one browser.

Keys that differ between the Chrome and Firefox manifestsCount of differing top-level manifest keys for a hand-maintained pair of manifests over four releases compared with a generated pair.Hand-maintained, release 14 differing k…Hand-maintained, release 411 differing …driftGenerated, release 16 differing k…Generated, release 46 differing k…only platform differences
Hand-maintained copies drift a key or two per release; generated ones differ only where the platform requires it.

Cross-browser variation

  • Chrome / Edge: unknown manifest keys produce a warning on chrome://extensions rather than an error, which is why a Firefox-only key in a Chrome build often goes unnoticed. Edge accepts the Chrome manifest unchanged.
  • Firefox: stricter about unknown keys in some versions and rejects permissions it does not recognise. browser_specific_settings.gecko.id is required for AMO submission of MV3 add-ons.
  • Safari: the manifest is input to safari-web-extension-converter, which reports unsupported keys during conversion. The bundle identifier and team id live in the Xcode project, not the manifest.
  • All three: version must be one to four dot-separated integers. A pre-release suffix like 2.4.0-beta.1 from package.json must be stripped or mapped — Chrome allows a separate version_name for the human-readable form.

Verification

  1. Generate every target and compare the key-level diff against the expected snapshot.
  2. Load dist/chrome and dist/firefox in their browsers and confirm no manifest warnings appear on the extensions page.
  3. Confirm a store build never contains update_url:
1for t in chrome firefox safari; do grep -q update_url dist/$t/manifest.json && echo "$t has update_url!"; done

Execution context: your shell. No output is the only acceptable result.

  1. Bump package.json’s version and confirm all three manifests pick it up.

FAQ

Should I keep a manifest.json in the repository at all?

Keep the generator as the source of truth and do not commit generated output. A committed manifest will be edited by hand eventually, and then there are two sources.

How do I handle version_name for betas?

Map it in the generator: version stays numeric, version_name carries 2.4.0 beta 1. Chrome shows version_name to users; Firefox ignores it.

Is a JSON template with placeholders enough?

For two targets with one difference, yes. Once targets add and remove keys, a template becomes harder to read than code — the generator above is roughly the same length and considerably clearer.

Can the generator localise the extension name?

Leave localisation to the browser: use __MSG_extName__ placeholders in the manifest and ship _locales/<lang>/messages.json. The generator should not substitute translated strings itself, because the browser picks the locale at runtime from the user’s settings, not at build time. The translation side is in translating manifest fields and store listings.

What about Edge and Opera?

Both accept the Chrome output unchanged. Add a target only when a store requires a genuinely different key — until then, the Chrome package is the Edge and Opera package, and fewer targets means fewer ways for builds to diverge.

Other MV3 Architecture & Extension Lifecycle Resources