Skip to main content
CRXJS is a Vite plugin for Chrome extensions. If you have outgrown the Chrome-only scope, want first-class Firefox output, or hit reload-loop quirks, this guide migrates a typical CRXJS project to Extension.js without rewriting your UI code.
Landing here because vite build fails with [crx:manifest-post] Content script fileName is undefined on Vite 8? See the dedicated fix page for workarounds before migrating.

What changes, what stays

Stays the same: your React/Vue/Svelte components, your Tailwind config, your tests, your chrome.* API calls. Changes:
  • vite.config.ts + @crxjs/vite-plugin becomes extension.config.js (or no config at all).
  • manifest.config.ts (TypeScript module) becomes plain manifest.json with browser-prefixed keys.
  • vite / vite build scripts become extension dev / extension build.
  • Extension.js dev replaces the Vite dev server, with browser launch, profile management, and per-target reload built in.

Step 1: install Extension.js

If your project also uses Vite for non-extension surfaces (a marketing site, for example), keep Vite installed scoped to that workspace.

Step 2: convert the manifest

CRXJS uses a TypeScript module:
manifest.config.ts
In Extension.js, write a real manifest.json and reference real files:
manifest.json
Notes:
  • File extensions stay .ts/.tsx in the manifest. Extension.js compiles them at build time.
  • Drop the src/ prefix if you flatten directories. Extension.js follows whatever paths your manifest declares.
  • For browser-specific values, use prefixed keys (firefox:browser_specific_settings) rather than per-build manifests.

Step 3: update package.json scripts

Replace:
With:
See Commands reference for the full set.

Step 4: remove vite.config.ts

If your vite.config.ts only existed to wire CRXJS, delete it. If it has other plugins, move equivalents to extension.config.js or to Rspack configuration for advanced bundler customization.

Step 5: handle HMR differences

CRXJS HMR pushes updates over a Vite WebSocket. Extension.js uses a different model documented in Reload and HMR:
  • Popup, options, devtools pages: HMR.
  • Content scripts: targeted reload.
  • Background service worker: full restart.
If your code depended on Vite’s import.meta.hot for content-script logic, replace those branches with regular module code. Extension.js handles reload orchestration outside your source.

Step 6: cross-browser output

CRXJS targets Chromium. To start emitting Firefox output too:
You get dist/chrome and dist/firefox with browser-correct manifests and .zip archives ready for the Chrome Web Store and addons.mozilla.org. See Cross-browser compatibility.

Step 7: verify

Verify popup, options, content scripts, and background behavior. Then validate Firefox:
Code that calls chrome.* runs on Firefox as written, because Firefox supports the chrome.* namespace natively. The --polyfill flag is for the other direction: it supplies browser.* on Chromium targets. extension dev applies it by default, and extension build leaves it off unless you pass the flag.

Common gotchas

  • Service worker import statements: Extension.js bundles your background entry, so import in your source needs no manifest change. Extension.js does not add "type": "module" for you. Declare that key yourself if you want it, and the build keeps it.
  • web_accessible_resources typing: Manifest V3 uses [{resources, matches}] blocks. Both frameworks emit the right shape, so copy your existing entries verbatim.
  • Hashed asset paths: production builds under dist/<browser> use clean names. extension dev appends a build hash to content scripts, for example content_scripts/content-0.813e908a.js, to defeat the extension resource cache. See Reload and HMR. If your code hardcoded Vite-style hashed filenames, replace them with manifest-relative paths.

See also