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.
Table of Contents
- Where the targets actually differ
- Step-by-step
- 1. Write the manifest as code
- 2. Apply per-target differences as small, explicit functions
- 3. Write it into each target’s output directory
- 4. Add a self-hosted variant without polluting the store builds
- 5. Validate each manifest against what the build produced
- 6. Lint each target with the browser’s own tools
- Keeping the targets from drifting apart
- Cross-browser variation
- Verification
- FAQ
- Related
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
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.
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.
Cross-browser variation
- Chrome / Edge: unknown manifest keys produce a warning on
chrome://extensionsrather 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.idis 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:
versionmust be one to four dot-separated integers. A pre-release suffix like2.4.0-beta.1frompackage.jsonmust be stripped or mapped — Chrome allows a separateversion_namefor the human-readable form.
Verification
- Generate every target and compare the key-level diff against the expected snapshot.
- Load
dist/chromeanddist/firefoxin their browsers and confirm no manifest warnings appear on the extensions page. - 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.
- 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.
Related
- Shipping one manifest for Chrome and Firefox — how far a single manifest can go before generation is needed.
- Bundling an MV3 extension with Vite — the step that runs before this one.
- Publishing to Firefox add-ons and Safari — what each generated package is for.
- Build tooling and bundlers — the parent guide.