
6 min read
From the studio
We build apps people actually keep.Tinkrd is a small indie studio making focused, high-quality apps for everyday life. See what we're working on.
View our appsBuilding a Chrome extension with React is completely doable, but the setup is not as obvious as a standard React app. The browser extension environment has its own rules, and React’s usual tooling does not map to it cleanly out of the box. Here is what actually works, without the hand-waving.
Why Use React for a Chrome Extension at All
React for Chrome extension development makes sense when your extension has real UI, like a popup, options page, or side panel with multiple interactive states. If your extension is just a few lines of content script logic, plain JavaScript is honestly the better call. But once you have forms, conditional rendering, component reuse across popup and options pages, or any state management beyond a toggle, React earns its place.
The tradeoff is setup complexity. Chrome extensions do not use a single HTML entry point the way a web app does. They have multiple contexts: background service workers, content scripts, and one or more HTML pages. React needs to be bundled correctly for each of those.
Setting Up the Project Structure
The most reliable approach is to use Vite with a multi-page config, or to use a purpose-built starter like CRXJS. Both handle the multi-entry-point problem that plain Create React App fumbles on.
Option 1: Vite with CRXJS
CRXJS is a Vite plugin that understands the Chrome extension manifest and wires up hot module replacement correctly. This is the setup we landed on at Tinkrd after trying a few others.
npm create vite@latest my-extension -- --template react
cd my-extension
npm install -D @crxjs/vite-plugin
Then update your vite.config.js:
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 })]
})
Option 2: Vite Multi-Page Without CRXJS
If you want more manual control, you can configure Vite’s build.rollupOptions.input to point at each HTML file your extension needs, popup.html, options.html, and so on. You then reference the built output in your manifest yourself. It is more work but gives you full control over the output.
The Manifest File and React Entry Points
The manifest.json file is the heart of any Chrome extension. It tells the browser which files serve which roles. When you use React, each UI surface (popup, options, side panel) needs its own HTML file that mounts a React root.
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0",
"action": {
"default_popup": "popup.html"
},
"options_page": "options.html",
"background": {
"service_worker": "src/background.js",
"type": "module"
}
}
Each HTML file mounts its own React app. The background service worker is plain JavaScript, not React, because it has no DOM. Keep that boundary clear and you will save yourself a confusing hour of debugging.
Content Scripts and React: Know the Limits
Content scripts run inside the user’s active tab. You can inject a React component into the page’s DOM from a content script, but you need to be careful. You are mounting into a live third-party page, which means style conflicts and DOM interference are real problems.
The standard approach is to create a shadow DOM root and mount your React app into that. This isolates your styles and prevents the host page from breaking your UI.
// content.js
const host = document.createElement('div')
const shadow = host.attachShadow({ mode: 'open' })
document.body.appendChild(host)
import { createRoot } from 'react-dom/client'
import App from './App'
const root = createRoot(shadow)
root.render( )
This pattern keeps your component tree inside the shadow root, away from the host page’s CSS and scripts.
Tooling Comparison at a Glance
Choosing the right build setup saves a lot of rework. Here is an honest comparison of the main options:
| Tool | HMR Support | Manifest v3 Ready | Setup Effort | Best For |
|---|---|---|---|---|
| Vite + CRXJS | Yes | Yes | Low | Most projects |
| Vite multi-page | Partial | Yes | Medium | Custom build needs |
| Webpack + custom config | Partial | Yes | High | Existing Webpack projects |
| Create React App | No (for extensions) | Awkward | Medium | Not recommended |
If you are starting fresh, Vite with CRXJS is the path of least resistance. For a deeper walkthrough of wiring everything together, the complete guide to building React Chrome extensions covers the full process end to end.
Debugging Your React Chrome Extension
Debugging a React extension is slightly different from debugging a regular web app. Each context (popup, options, content script, background) has its own DevTools instance. Right-clicking the popup and choosing Inspect opens DevTools scoped to that popup window.
The React Developer Tools browser extension works inside these popup DevTools windows, so you can inspect component trees and props normally. If you are not already using it, the React Developer Tools Chrome extension is worth setting up before you start debugging anything complex.
For the background service worker, go to chrome://extensions, find your extension, and click the “service worker” link. That opens a dedicated DevTools window for the background context. Console logs from your service worker appear there, not in the regular page DevTools.
Frequently Asked Questions
Can I use React hooks in a Chrome extension?
Yes, React hooks work exactly the same inside an extension popup or options page as they do in any React app. The only place hooks do not apply is the background service worker, because that context has no React at all.
Does React slow down a Chrome extension popup?
For a small popup, the bundle size is worth watching. A basic React app after tree-shaking runs around 40 to 50KB gzipped. For most popups that is fine, but if your extension just toggles a setting, vanilla JS is genuinely faster to load. Profile before optimizing.
How do I share state between the popup and content script?
You use Chrome’s messaging API, chrome.runtime.sendMessage and chrome.runtime.onMessage, to pass data between contexts. You can also persist shared state in chrome.storage.local and read it from whichever context needs it. React state itself does not cross context boundaries.
Is Manifest v3 compatible with React?
Yes. React has no dependency on the manifest version. Manifest v3 mainly affects the background service worker (no persistent pages) and some API changes. Your React popup and options pages are unaffected by the v3 migration.
Do I need TypeScript for a React Chrome extension?
TypeScript is optional but genuinely useful here. Chrome’s extension APIs have well-maintained type definitions via @types/chrome, and having types on chrome.runtime.sendMessage calls saves time. You can add it with npm install -D typescript @types/chrome and a basic tsconfig.json.
Getting React working in a Chrome extension takes a bit more setup than a standard web app, but the patterns are stable and the tooling has improved a lot. Start with Vite and CRXJS, keep your contexts clear, and the rest follows naturally.
We’re building in the open
Tinkrd is a small studio making thoughtful apps for families. Our first app is launching soon.