Using React for Chrome Extensions: What You Actually Need to Know
Photo by Meet Patel on Pexels

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 apps

Building 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.

See what we’re building

T
The Tinkrd Team

We are a small independent app studio in California. We write about what we are learning as we build and ship products, from first commit to first customer.