CRXJS is a Chrome extension development tool that provides true HMR for popup, options, content scripts, and side panels. It reads your manifest to auto-generate the extension output, handles content script injection, and manages the service worker build. Under the hood it is a Vite plugin ( @crxjs/vite-plugin).
@crxjs/vite-plugin (v2.x stable, latest v2.4.0 as of March 2026)npm create crxjs@latest (always use @latest)bash
# Scaffold new project (picks framework interactively)
npm create crxjs@latest
# Or add to existing Vite project
npm install @crxjs/vite-plugin -D
CRXJS is added as a Vite plugin. The setup varies slightly per framework.
typescript
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { crx } from "@crxjs/vite-plugin";
import manifest from "./manifest.json";
export default defineConfig({
plugins: [react(), crx({ manifest })],
});
Use @vitejs/plugin-react (not plugin-react-swc) for best HMR compatibility. If you must use SWC, cast the manifest:
typescript
import { ManifestV3Export } from "@crxjs/vite-plugin";
const manifest = manifestJson as ManifestV3Export;
typescript
import vue from "@vitejs/plugin-vue";
import { crx } from "@crxjs/vite-plugin";
import manifest from "./manifest.json";
export default defineConfig({
plugins: [vue(), crx({ manifest })],
});
typescript
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { crx } from "@crxjs/vite-plugin";
import manifest from "./manifest.json";
export default defineConfig({
plugins: [svelte(), crx({ manifest })],
});
typescript
import { crx } from "@crxjs/vite-plugin";
import manifest from "./manifest.json";
export default defineConfig({
plugins: [crx({ manifest })],
});
Instead of a static JSON file, use CRXJS's defineManifest for dynamic values and full TypeScript autocompletion:
typescript
// manifest.ts
import { defineManifest } from "@crxjs/vite-plugin";
import pkg from "./package.json";
export default defineManifest((config) => ({
manifest_version: 3,
name: config.command === "serve" ? `[DEV] ${pkg.name}` : pkg.name,
version: pkg.version,
description: pkg.description,
permissions: ["storage", "activeTab", "scripting"],
action: {
default_popup: "src/popup/index.html",
default_icon: {
"16": "public/icons/icon16.png",
"48": "public/icons/icon48.png",
},
},
background: {
service_worker: "src/background/index.ts",
type: "module",
},
content_scripts: [
{
matches: ["https://*/*"],
js: ["src/content/index.ts"],
css: ["src/content/styles.css"],
},
],
options_page: "src/options/index.html",
side_panel: { default_path: "src/sidepanel/index.html" },
icons: {
"16": "public/icons/icon16.png",
"48": "public/icons/icon48.png",
"128": "public/icons/icon128.png",
},
}));
Import in vite.config.ts:
typescript
import manifest from "./manifest";
// ... crx({ manifest })
Add to a src/vite-env.d.ts or src/crxjs.d.ts:
typescript
/// <reference types="@crxjs/vite-plugin/client" />
This enables types for ?script and ?script&module imports.
ContextHMRHow it worksPopupFull HMRWebSocket-based, state preservedOptions pageFull HMRSame as popupSide panelFull HMRSame as popupContent script (manifest)True HMRCRXJS injects loader + HMR clientContent script (dynamic)True HMRVia ?script importService workerAuto-reloadChanges trigger full extension reloadMain world scriptsNo HMRSkipped by CRXJS loader
Content script HMR works because CRXJS generates a loader script that imports an HMR preamble, the HMR client, and your actual script — enabling real module-level HMR without full page reload. This is CRXJS's main differentiator.
For content scripts injected programmatically (not in manifest), CRXJS provides special import suffixes:
typescript
// background.ts — ?script gives you a resolved path for executeScript
import contentScript from "./content?script";
chrome.action.onClicked.addListener(async (tab) => {
await chrome.scripting.executeScript({
target: { tabId: tab.id! },
files: [contentScript],
});
});
For main world injection (no HMR):
typescript
import mainWorldScript from "./inject?script&module";
await chrome.scripting.executeScript({
target: { tabId },
world: "MAIN",
files: [mainWorldScript],
});
typescript
crx({
manifest,
browser: "chrome", // 'chrome' | 'firefox'
contentScripts: {
injectCss: true, // auto-inject CSS for content scripts
hmrTimeout: 5000, // HMR connection timeout (ms)
},
});
bash
# Start dev server (outputs to dist/ with HMR)
npm run dev
# 1. Open chrome://extensions
# 2. Enable "Developer mode"
# 3. Click "Load unpacked"
# 4. Select the dist/ directory
# 5. Edit code — popup/content scripts update instantly via HMR
# 6. Service worker changes trigger automatic extension reload
After loading once, subsequent npm run dev sessions reconnect automatically. No need to re-load the extension unless manifest.json changes.
bash
npm run build # outputs to dist/
The dist/ directory is ready to zip and upload to Chrome Web Store:
bash
cd dist && zip -r ../extension.zip .
Disable Vite's module preload to avoid CWS rejection of inline scripts:
typescript
build: {
modulePreload: false;
}
New Tailwind classes may not trigger CSS updates in content scripts. Workaround: restart dev server after adding new utility classes. Improved in v2.4.0 but not fully resolved. Ensure injectCss: true in config.
ws://localhost:undefined/)#Cause: port mismatch between dev server and HMR config. Fix: explicitly set both to the same value:
typescript
server: {
port: 5173,
strictPort: true,
hmr: { port: 5173 },
}
If you see this, your manifest is being interpreted as MV2. Fix: ensure "manifest_version": 3 is set.
Chrome requires the user to enable "Allow access to file URLs" in the extension settings at chrome://extensions. CRXJS cannot change this.
CRXJS's HMR relies on injecting a content script that connects to the dev server's WebSocket. Chrome security updates occasionally break this. Fix: update to the latest CRXJS version, which tracks Chrome changes.
FeatureCRXJSWXTPlasmoContent script HMRTrue HMRFile-based reloadPartialFramework supportAny Vite frameworkAnyReact-focusedAbstraction levelThin (Vite plugin)Full frameworkFull frameworkMessaging helpersNone (use chrome.* directly)Built-inBuilt-inStorage wrappersNoneBuilt-inBuilt-inCross-browserChrome + FirefoxChrome + Firefox + SafariChrome + FirefoxFile-based routingNoYesYesLearning curveLow (know Vite, know CRXJS)MediumMedium
Choose CRXJS when: you want minimal abstraction over raw Chrome APIs and value content script HMR above all. CRXJS stays out of the way — no magic routing, no wrapper APIs, just your code with HMR.
Choose WXT when: you want conventions, built-in utilities, and cross-browser support.
Choose Plasmo when: you're React-focused and want the highest-level abstraction.
text
my-extension/
├── src/
│ ├── background/
│ │ └── index.ts
│ ├── content/
│ │ ├── index.ts
│ │ └── styles.css
│ ├── popup/
│ │ ├── index.html <- CRXJS resolves HTML entry points
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── options/
│ │ ├── index.html
│ │ └── main.tsx
│ ├── sidepanel/
│ │ ├── index.html
│ │ └── main.tsx
│ └── shared/
│ ├── messages.ts
│ └── storage.ts
├── public/
│ └── icons/
├── manifest.ts <- or manifest.json
├── vite.config.ts
├── tsconfig.json
└── package.json
CRXJS resolves HTML files referenced in the manifest automatically. Your popup.html can use standard <script type="module" src="./main.tsx"> and it works.
If you encounter a bug or unexpected behavior in CRXJS, open an issue at github.com/crxjs/chrome-extension-tools/issues.