If you have attempted to bring the modern React developer experience (DX) to Chrome Extension development, you have likely hit a specific, workflow-killing wall. You set up Vite, get the popup rendering locally, but the moment you touch a Content Script, the abstraction leaks.
HMR stops working. Styles don't update. You find yourself manually reloading the extension in chrome://extensions and refreshing the target web page just to see a one-line code change.
This creates a feedback loop latency that makes modern UI development impossible. This guide details exactly why standard Vite configurations fail in the browser extension context and provides a rigorous, production-ready architecture using React, Vite, TypeScript, and CRXJS to solve it.
The Root Cause: Why Standard HMR Fails in Extensions
To fix the problem, we must understand the architecture of the browser runtime. Standard Vite HMR relies on a WebSocket connection between the browser client and the local development server (usually ws://localhost:5173).
1. The Context Isolation Barrier
Content Scripts do not run in the same context as your popup or options page. They run inside the context of the host page (e.g., google.com).
When Vite tries to inject its HMR client into a Content Script, two security mechanisms often trigger:
- CSP (Content Security Policy): The host page may block script execution from external sources (localhost).
- Mixed Content: If the host page is HTTPS (secure), Chrome blocks WebSocket connections to an insecure
ws://localhost endpoint.
2. The Orphaned Script Problem
Even if you establish a connection, Chrome Extensions have a unique lifecycle quirk. When an extension reloads (triggered by a rebuild), the Chrome Runtime sends a signal to invalidate the previous context.
However, the DOM elements injected by your previous Content Script remain on the page. The Javascript context that created them is dead (disconnected from the extension API), but the HTML remains. This leads to the "Extension context invalidated" error and duplicate UI elements stacking up on the page until you manually refresh the tab.
The Solution: A Dedicated Vite Ecosystem
We will not hack a Webpack config. We will use @crxjs/vite-plugin. This plugin is currently the gold standard for bridging Vite's build process with the Chrome Manifest V3 API. It handles HMR injection and content script reloading automatically.
Step 1: Initialize the Project
Start with a fresh Vite + React + TypeScript scaffold.
npm create vite@latest chrome-ext-pro -- --template react-ts
cd chrome-ext-pro
npm install
npm install @crxjs/vite-plugin@beta -D
Note: We use the beta version of CRXJS as it supports Vite 4/5 and the latest ESM handling better than the stable legacy version.
Step 2: Formalize the Manifest
Create a manifest.json file in your root directory (not inside public). CRXJS reads this file to determine the entry points for the build.
{
"manifest_version": 3,
"name": "React Vite Extension",
"version": "1.0.0",
"action": {
"default_popup": "index.html"
},
"content_scripts": [
{
"js": ["src/content/index.tsx"],
"matches": ["https://*.google.com/*"],
"run_at": "document_end"
}
]
}
Step 3: Configure Vite
Modify vite.config.ts. The crucial step here is importing the manifest and passing it to the plugin. This tells Vite to treat src/content/index.tsx as a script entry point rather than a static asset.
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 }),
],
server: {
// strictPort ensures the HMR client connects to the correct port
port: 5173,
strictPort: true,
hmr: {
clientPort: 5173,
},
},
});
The Injection Strategy: Shadow DOM and React Roots
Simply appending a React App to document.body is dangerous. The host page's CSS will bleed into your extension, and your extension's generic global styles (like resets) might break the host page.
We must use the Shadow DOM to create a style-encapsulated boundary.
Create src/content/index.tsx. This file acts as the bootstrapper. It detects if our root exists, handles cleanup (solving the orphan problem), and mounts the React application.
import { createRoot } from 'react-dom/client';
import ContentApp from './ContentApp';
import './content.css'; // Global styles for the shadow root
const ROOT_ID = 'my-extension-root';
function mount() {
// 1. Cleanup previous instances (Fixes the "Orphaned Script" issue)
const existingRoot = document.getElementById(ROOT_ID);
if (existingRoot) {
existingRoot.remove();
}
// 2. Create the Host Element
const hostElement = document.createElement('div');
hostElement.id = ROOT_ID;
// High z-index ensures it floats above page content
hostElement.style.position = 'fixed';
hostElement.style.zIndex = '2147483647';
hostElement.style.top = '0';
hostElement.style.right = '0';
document.body.appendChild(hostElement);
// 3. Create Shadow DOM (Style Encapsulation)
const shadowRoot = hostElement.attachShadow({ mode: 'open' });
// 4. Inject Styles
// In Vite content scripts, imported CSS is injected into the head by default.
// We need to manually move relevant styles into our Shadow Root.
const styleElement = document.createElement('style');
// Simple reset for the shadow DOM context
styleElement.textContent = `
:host { all: initial; }
div { font-family: sans-serif; }
`;
shadowRoot.appendChild(styleElement);
// 5. Mount React
const root = createRoot(shadowRoot);
root.render(<ContentApp />);
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount);
} else {
mount();
}
The Component Layer
Create src/content/ContentApp.tsx. This is a standard React component.
import { useState } from 'react';
export default function ContentApp() {
const [isOpen, setIsOpen] = useState(true);
if (!isOpen) return null;
return (
<div style={{
padding: '20px',
backgroundColor: '#1a1a1a',
color: 'white',
borderRadius: '0 0 0 10px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
}}>
<h2 style={{ margin: '0 0 10px 0', fontSize: '16px' }}>
Dev Mode Active
</h2>
<p style={{ margin: '0 0 15px 0', fontSize: '14px', color: '#ccc' }}>
Edit this text in VS Code and watch it update instantly.
</p>
<button
onClick={() => setIsOpen(false)}
style={{
padding: '8px 16px',
background: '#646cff',
border: 'none',
borderRadius: '4px',
color: 'white',
cursor: 'pointer'
}}
>
Close
</button>
</div>
);
}
Handling CSS in Shadow DOM
The code above includes a manual style injection. However, if you are importing CSS files (e.g., Tailwind or CSS Modules), Vite usually injects these into the document <head>. This renders them useless inside your Shadow Root.
To fix this with @crxjs/vite-plugin, you can import the CSS specifically as a string using the ?inline query suffix (a Vite feature) and inject it.
Update src/content/index.tsx to handle external CSS imports:
// Import CSS as a string
import styles from './content.css?inline';
// ... inside the mount function
const styleSheet = document.createElement('style');
styleSheet.textContent = styles;
shadowRoot.appendChild(styleSheet);
This ensures your Tailwind or custom CSS applies only to your extension UI, maintaining complete isolation from the host page.
Deep Dive: Why This Architecture Works
This setup solves the retention and refreshing issues through three specific mechanisms:
- HMR Proxying: The
@crxjsplugin wraps the standard Vite HMR client. It establishes a unified connection that proxies HMR events securely, bypassing the Mixed Content errors that typically blocklocalhostconnections on HTTPS sites. - Manifest-Driven Build: By treating
manifest.jsonas the source of truth, Vite constructs a dependency graph that understands the difference between a popup (HTML entry) and a content script (JS entry). - Shadow DOM Boundary: By mounting into a Shadow Root, we prevent the "CSS War" where site styles break your extension. This reduces debugging time significantly.
Common Pitfalls and Edge Cases
The "Extension Context Invalidated" Error
You will still see this error in the console when you edit the vite.config.ts or manifest.json. These changes require a full extension reload. However, for standard React component changes, HMR will handle the update gracefully without severing the context connection.
Handling Assets (Images/Fonts)
In a standard web app, you import images and they resolve to a URL. In a Chrome Extension, resources must be explicitly web-accessible.
If you import an image in your React component: import logo from '../../assets/logo.png';
You must ensure manifest.json allows the browser to load it:
"web_accessible_resources": [
{
"resources": ["assets/*"],
"matches": ["<all_urls>"]
}
]
Conclusion
Building Chrome Extensions doesn't require abandoning modern tooling. By understanding the underlying constraints of the Chrome Runtime—specifically context isolation and resource security—you can leverage Vite and CRXJS to build a development environment that rivals standard web development.
You now have a setup where saving a file updates the DOM on google.com (or your target site) in milliseconds, with full style encapsulation and TypeScript safety.