Chrome Extensions

Building a Chrome Extension With React: Lessons Learned

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.

Illustration of a Chrome extension popup open in the browser, Building a Chrome Extension With React: Lessons Learned
Lessons from building a Chrome extension with React: setup, why popup state vanishes, CSP rules, message passing and the mistakes to avoid.

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.

Infographic summarising “Building a Chrome Extension With React: Lessons Learned”: Setting up a Chrome extension with React; The popup is not a long-lived app; Mind the CSP and the separate worlds; Common mistakes with React extensions; Before you ship
The article at a glance.

Common mistakes with React extensions

  • Testing only in dev mode. Load the packed build regularly; that is what reviewers and users get.
  • Using setTimeout for long delays in the worker. The worker may shut down before it fires. Use chrome.alarms for 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.

Questions people ask

Can you build a Chrome extension with React?

Yes. React works well for the popup, options page, side panel and injected widgets. The background service worker has no DOM, so it stays plain JavaScript or TypeScript. The main adjustments are keeping state in chrome.storage and talking between parts with message passing.

Why does my extension popup lose its state when it closes?

The popup is destroyed every time the user clicks away, so React state disappears with it. Anything that must survive has to be saved in chrome.storage and read back when the popup opens again. Listening for storage changes keeps the popup in sync with the background worker.

Why does my React extension work in development but fail when packed?

Usually the Manifest V3 Content Security Policy. It bans inline scripts and eval, and some bundler setups produce code that breaks those rules. Use a build that outputs CSP-safe bundles, and load the packed build with Load unpacked early and often rather than relying on the dev server.

How do I stop a page's CSS from breaking my injected React widget?

Mount the widget inside a Shadow DOM root created by your content script. That isolates its styles from the host page in both directions, so the page's CSS does not restyle your widget and your styles do not leak onto the page.

Comments

Leave a comment

0 / 3000
Comments are moderated. Links are not clickable and your email is never published. See the privacy policy.
keep reading

Related articles

Usually replies within a day
let’s talk

Tell me what you want to build

A website, an app, some automation, or something you’re still figuring out. Tell me the problem and I’ll give you a straight answer on what it takes. No pressure, no sales team.