Putting React inside a Chrome extension sounds simple. It’s just a web app in a popup, right? Mostly, but the extension environment has rules that trip up anyone treating it like a normal React project. A few lessons save a lot of pain.

Setting up a Chrome extension with React
A normal React app has one entry point. An extension usually has several: the popup, maybe an options page or side panel, a background service worker, and content scripts that run inside web pages. Each one is its own bundle, and the manifest has to point at the built files, not your source.
You can wire that up by hand with Vite or Webpack and several entries, or use a tool built for it, such as WXT, Plasmo, or Vite with the CRXJS plugin. Whichever you choose, check three things on day one:
- The build output loads. Turn on developer mode at
chrome://extensions, choose “Load unpacked”, and point it at the build folder, not the project root. - Only the UI parts use React. The background service worker has no DOM, so it should be plain TypeScript or JavaScript with no React in it.
- The popup fits. Chrome caps the popup at 800 by 600 pixels, so design for a small, fixed window rather than a responsive page.
The popup is not a long-lived app
It mounts when the user clicks the icon and is destroyed the moment they click away. Any state in the component is gone. So never rely on in-memory state to persist: anything that must survive a close has to live in chrome.storage, not React state. Treat the popup as a view that reads from and writes to storage.
In practice I write one small hook that reads a value from the chrome.storage API on mount, writes changes back, and listens to chrome.storage.onChanged. That last part matters: if the background worker or another page updates a value, the popup reflects it without a reload.
Pick the right storage area, too. chrome.storage.local is the sensible default for most data. chrome.storage.sync follows the user across their signed-in browsers but has tight size limits, so keep it for settings. chrome.storage.session is held in memory and cleared when the browser closes, which suits temporary values the worker needs between wake-ups.
Because the popup starts from scratch on every click, startup speed is part of the user experience. Keep its bundle small, render a light loading state while storage is read, and avoid pulling a large UI library into a window that opens and closes all day.
Mind the CSP and the separate worlds
Manifest V3’s Content Security Policy bans inline scripts and eval. Some bundler defaults violate this, so build the extension with a setup that outputs CSP-safe bundles and test the packed build early. What runs with the dev server may be rejected when packed.
Understand the pieces, too. The popup, the background service worker, and content scripts are separate worlds that can’t share memory; they communicate through message passing. And the service worker isn’t persistent. It spins up to handle events and shuts down, so don’t keep important state only in its memory.
Message passing without surprises
Give every message a clear type and keep the types in one shared file that every part of the extension imports. Content scripts reach the background with chrome.runtime.sendMessage, and the background reaches a specific tab with chrome.tabs.sendMessage. Replies that need async work have their own rules, which the Chrome messaging guide sets out clearly.
React in a content script
If you inject a React widget into someone else’s page, the page’s CSS will leak into it and yours can leak out. Mounting the widget inside a Shadow DOM root keeps the two apart. Content scripts can read and change the page’s DOM, but they run in an isolated world, so they cannot see the page’s own JavaScript variables.
Watch for double effects in development
React’s Strict Mode runs effects twice in development. In an extension, that can register a storage or message listener twice, or send the same message twice. Always return a cleanup function that removes the listener. If double runs confuse you, I explain the cause in why your React app re-renders twice on every click.

Common mistakes with React extensions
- Testing only in dev mode. Load the packed build regularly; that is what reviewers and users get.
- Using
setTimeoutfor long delays in the worker. The worker may shut down before it fires. Usechrome.alarmsfor anything scheduled. - Reading storage on every render. Read once, keep it in state, and update from the change listener.
- Asking for broad permissions to make development easier. Those permissions ship with the extension and slow down review.
Before you ship
Build around storage for persistence and messages for communication, and React in an extension becomes pleasant instead of mysterious.
Two more reads before submitting. Storage choices have security consequences, which I cover in how to store data securely in a Chrome extension. And the review itself has its own traps, listed in common Manifest V3 mistakes that get extensions rejected. If you would rather have the extension built for you, that sits within my web application development work.




