How to Build a Chrome Extension with React

Learn how to create a powerful Chrome extension using React and Plasmo framework with this comprehensive step-by-step guide including code examples.

Share

If you want to build a Chrome extension with React, drop the mindset of a traditional single-page web application. A modern Manifest V3 extension is not a single app. It consists of three completely isolated runtimes (a popup UI, a background service worker, and a DOM-injected content script) coordinating securely through browser storage and messaging. This guide skips outdated legacy setups to show you exactly how to scaffold, inject, test, and publish a production-ready extension using React and Vite.

  1. Companion GitHub repo
  2. Chrome Developers docs hub

How do you build a Chrome extension with React?

To build a Chrome extension with React, use Manifest V3 to define your architecture, render React components strictly in your UI surfaces (like the popup or injected DOM), and keep background logic outside of React. Connect your React UI, content scripts, and event-driven service worker using chrome.storage for persistent state and Chrome messaging APIs for cross-context communication.

Understand the MV3 architecture before you write code

Key Takeaways:

  • React strictly handles your UI surfaces.
  • Native extension APIs handle browser behavior.
  • Storage and messaging handle cross-context state.

The three isolated runtimes

Your extension relies on three strictly separated environments. The popup acts as the user-triggered UI surface. The content script serves as the host page DOM access layer. The service worker executes event-driven background logic. These pieces share no memory, DOM, or React state.

What React does and what Chrome APIs do

React belongs exclusively in your UI layers. Use it for the popup, options page, side panel, or injected on-page UI. React does not replace permissions, background messaging, browser event listeners, or service worker lifecycle management.

The two lifecycle traps most tutorials skip

Popup UI closes immediately on blur, instantly destroying local React state. Chrome also terminates service workers after roughly 30 seconds of inactivity, wiping all in-memory global variables.

A developer spends hours debugging why their user authentication resets randomly, only to realize they stored the token in a service worker variable instead of chrome.storage. The worker went to sleep, and the token vanished.

How is a React Chrome extension different from a regular React app?

A Chrome extension runs across multiple isolated contexts. The popup closes on blur, content scripts run in separated DOM worlds, and the service worker shuts down after brief inactivity. Shared state must move entirely through browser storage and explicit messaging rather than React context or Redux.

Choose the right build stack: Vite + CRXJS vs WXT vs Plasmo

Use Vite + CRXJS to learn real extension architecture without heavy abstractions hiding the platform fundamentals.

We recommend Vite + CRXJS. React officially marks Create React App as deprecated. CRXJS parses your manifest.json directly and supports full Hot Module Replacement (HMR) for all environments, including content scripts. It keeps the native manifest visible instead of hiding the underlying platform.

When WXT is a better fit

WXT fits teams targeting multiple browsers. It offers file-based entry points, an opinionated structure, and automated packaging. WXT explicitly avoids changing how you interact with native extension APIs.

When Plasmo makes sense

Plasmo is highly abstracted and includes a Next.js-style quickstart. This matters for developers specifically seeking a nextjs chrome extension setup. Extension pages use static HTML paths. If you introduce a router later, you must use hash mode rather than standard path routing.

What is the best React Chrome extension boilerplate or build tool?

For this guide, use Vite + CRXJS. CRXJS keeps the manifest visible while adding Vite-style HMR. Use WXT when you want more multi-browser abstraction. Use Plasmo when you prefer a highly opinionated, framework-driven workflow for building your UI and routing.

Scaffold the React + TypeScript project

Start with a clean Vite scaffold, add the CRXJS plugin, and organize your folders explicitly around the MV3 architecture.

Start with a React + TypeScript base

Use a standard Vite React TypeScript starter. Add the @crxjs/vite-plugin package. Keep the setup minimal. You do not need a bloated react typescript chrome extension boilerplate to get started.

Use an extension-first folder tree

Avoid default web-app structures. Separate your specific environments:

  • src/popup/ for the popup React app.
  • src/content/ for the injected page UI.
  • src/background/ for the service worker.
  • src/lib/ for shared utilities.
  • public/icons/ for static assets.

Keep shared logic separate from surface-specific logic

Place message type definitions, storage helpers, URL utilities, and constants inside src/lib/. Keep your popup and worker code extremely thin.

How do you start a Chrome extension with React and TypeScript?

Start with a Vite React TypeScript app, add the CRXJS plugin, and create explicit separate entry points for the popup, content script, and service worker. Structuring the project around the architectural boundaries makes debugging easier and accelerates your understanding of the platform model.

Configure manifest.json for Manifest V3

Build the manifest in layers as your feature set grows instead of pasting a massive file all at once.

Manifest V2 is completely deprecated and disabled across all Chrome channels. There is no legacy fallback.

Minimal manifest

Start with the bare minimum required to load the extension:

  • manifest_version: 3
  • name
  • version
  • action.default_popup

Add runtime surfaces

Once the popup loads, introduce the logic layers:

  • background.service_worker (replacing legacy persistent background pages).
  • content_scripts
  • permissions
  • icons

Add production-ready keys later

Wait until you actively build the features to add options_ui, side_panel, optional host permissions, or content security policy rules.

What should manifest.json include in a React Chrome extension?

A working MV3 manifest requires manifest_version: 3, a name, version, an action for the popup, and explicit entry points like background.service_worker and content_scripts. Start minimal. Add scripts, host access, icons, and options selectively as your requested feature set grows.

Build the popup with React

The popup is disposable UI, not your app's permanent home.

Mount React into popup.html

Create a clean popup.html file. Load your main.tsx inside it. Popup JavaScript must reside in separate files due to strict extension Content Security Policy (CSP) constraints.

Read current-tab context

For our page-notes demo, the popup displays the current hostname. Rely on the activeTab permission. It grants temporary host access exclusively after an explicit user gesture like clicking the extension icon.

Persist popup state in chrome.storage

React useState resets entirely when the user clicks away. Save draft notes, visual settings, and active preferences into chrome.storage. Read from storage immediately on mount to hydrate the UI.

Respect popup lifecycle constraints

Treat the popup as a command center. Use it for quick actions, settings toggles, or launch commands. Never treat it as a long-running application flow.

A developer builds an intricate multi-step form inside the popup. The user clicks a link to copy a value from another window, the popup blurs, and all form progress instantly vanishes.

How do you build the popup UI in React?

Build the popup as a small React app mounted into popup.html. Treat it strictly as disposable UI. Popups close instantly when focus leaves, so store persistent values in chrome.storage rather than local React state. Use the popup for quick commands, not long-running processes.

Inject a React component into the page with a content script

To safely inject a React component into an external webpage, mount it inside a Shadow DOM to block the host site's CSS from breaking your layout.

What content scripts can and cannot do

Content scripts access the host page DOM directly. They execute inside an isolated world. Their JavaScript execution context remains entirely separate from the host page's variables.

Create a mount point and render React into it

To create a chrome extension inject react component setup, register the content script in the manifest. Programmatically generate a stable DOM root element. Append it to the body, then render your React tree into that exact container.

Use Shadow DOM to isolate styles

A common failure point occurs when the host website's global CSS cascades into your injected React component. Solve this by attaching a Shadow Root to your mount node. Shadow DOM explicitly scopes styles to its own subtree. This guarantees host CSS cannot break your UI.

Optional advanced path: inject an extension page in an iframe

For extreme isolation, inject an iframe pointing to a local extension HTML page. This completely evades the host's CSP. An iframe cannot access the host page DOM directly, making it safer but more restrictive than a direct content script mount.

A pixel-perfect injected React widget looks great on localhost but stretches into unrecognizable shapes when loaded on a news website featuring aggressive global table and div styling.

How do you inject a React component into a webpage?

Register a content script, programmatically create a mount node on the host page, and render your React component inside it. Use a Shadow Root for strict style isolation to prevent the host page's global CSS from breaking your injected UI. Keep native DOM interactions inside the content script.

Add the Manifest V3 service worker

The service worker handles events and dies quickly. Design exclusively for an ephemeral shutdown cycle.

What the service worker owns

The service worker handles install hooks, context menus, cross-context messaging, and orchestrates state changes. It does not control DOM elements or UI rendering.

The lifecycle rules that matter in production

Chrome terminates the service worker after roughly 30 seconds of inactivity. A single request can run for at most 5 minutes. Outstanding fetch() calls whose response takes more than 30 seconds to arrive risk abrupt termination.

Persist data instead of globals

Global variables disappear upon worker shutdown. Standard Web Storage (localStorage) is unavailable inside MV3 workers. Prefer chrome.storage for durable state. Use storage.session to retain data securely across worker wake cycles.

Register listeners at top level

Always register event listeners synchronously at the top level of your worker file. Nesting them inside async functions causes Chrome to miss the registration during a wake event.

Inspecting the service worker in DevTools keeps it artificially active. Close DevTools when testing real-world lifecycle behavior.

How does the Manifest V3 service worker work?

The MV3 service worker is event-driven and non-persistent. Chrome terminates it after roughly 30 seconds of inactivity, wiping any in-memory globals. Register listeners synchronously at the top level, store durable state in chrome.storage, and design exclusively for wake-sleep cycles.

Wire popup, content script, and service worker together with messaging and storage

Message passing acts as the event glue. Storage acts as the single source of truth.

For a page notes extension, the popup asks for the current page note. The service worker receives the request and reads chrome.storage. If the note requires page-specific DOM context, the worker queries the content script. Finally, the service worker returns the payload to the popup.

When to use each messaging API

  • Use runtime.sendMessage() for single requests.
  • Use tabs.sendMessage() to route data specifically to a content script.
  • Use runtime.connect() or tabs.connect() for long-lived communication channels.

The safest async response pattern

Newer browser versions support responding asynchronously via Promise returns. For maximum compatibility across Chrome versions, returning true from your listener remains the safest default async pattern.

Storage decision matrix

  • storage.local: Store larger durable local app data.
  • storage.sync: Store lightweight user settings meant to sync across logged-in Chrome browsers.
  • storage.session: Store memory-like state surviving worker restarts without persisting data to disk.

Type-safe messaging pattern

Declare a discriminated union in TypeScript for all message payloads (e.g., type Message = { type: 'GET_NOTE' } | { type: 'SAVE_NOTE', payload: string }). Place this type definition inside your src/lib/ folder.

How do Chrome extension contexts talk to each other?

Use runtime.sendMessage() or tabs.sendMessage() for one-off requests, and connect() for longer-lived channels. The popup, content script, and service worker exchange explicit messages, while chrome.storage holds the durable state they all read from and write to.

Ask for the right permissions and avoid CSP mistakes

Requesting broad permissions extends your Web Store review time. Keep access explicitly narrow.

Start with narrow permissions

Request exactly what your feature demands. For this demo, use:

  • activeTab
  • storage

The activeTab permission proves exceptionally powerful. It grants temporary host access exclusively after an explicit user gesture.

Add host permissions selectively

Use broad <all_urls> host permissions sparingly. Utilize optional_permissions or optional_host_permissions to let users consent to elevated access later. This reduces initial installation friction.

Why permissions dictate review speed

Broad host permissions, highly sensitive API requests, and obfuscated code trigger manual Web Store reviews. Narrow scopes pass through automated checks faster.

CSP in MV3

Manifest V3 pages operate under a strict Content Security Policy. You cannot load remotely hosted code, and you cannot relax the CSP with insecure script sources. Older boilerplates relying on inline scripts or unsafe evaluation fail immediately on load.

Which permissions do I need for a React Chrome extension?

Request the smallest permission set possible. activeTab serves as a strong default for user-invoked current-tab actions because access remains temporary. Add persistent host permissions only for site-wide automation, and use optional permissions to minimize install friction and review risk.

Load, test, and debug the extension

Key Takeaway: Debug each surface individually using its dedicated DevTools instance to avoid silent failures.

Load unpacked and reload fast

Navigate to chrome://extensions. Enable Developer Mode. Click "Load unpacked" and select your build output directory. Vite + CRXJS handles module reloading automatically.

Debug the popup

Right-click your extension icon and select "Inspect popup". This opens a dedicated DevTools window solely for your popup React app.

Debug the service worker

Go to chrome://extensions. Find your extension card. Click the blue "service worker" link next to "Inspect views".

Debug content scripts from the page itself

Content scripts inject directly into the target webpage. Their console logs and UI errors appear in the standard host page DevTools, not the extensions page.

Add one explicit shutdown test

Close all service worker DevTools windows. Wait 40 seconds for Chrome to terminate the background script. Attempt to use your extension feature. Ensure it wakes the worker and hydrates state properly.

Skipping the closed-DevTools shutdown test guarantees you will ship a bug that only happens to real users.

How do you test and debug a React Chrome extension?

Load the extension unpacked via chrome://extensions. Inspect the popup directly, inspect the service worker from the extension card, and inspect content scripts using the target webpage's DevTools. Close service-worker DevTools when testing lifecycle events to prevent the worker from staying artificially awake.

Publish to the Chrome Web Store without avoidable review friction

Fast publishing demands clean permissions and transparent privacy logic.

Build and package the release

Generate a production build with Vite. Zip the output directory. Verify no source maps or dead assets bloat the final bundle.

Complete the Dashboard fields

Navigate to the Chrome Developer Dashboard. Focus closely on the Store Listing, the Privacy tab, Distribution parameters, and Test Instructions. Reviewers strictly validate your Privacy justifications against your manifest permissions.

What increases review time

Automated checks flag broad host permissions, execution permissions, or unusually large JavaScript payloads. Obfuscation is entirely banned. Minified code requires clear functionality mapping during review.

Pre-submission checklist

  • Request the absolute smallest permissions possible.
  • Prepare accurate screenshots and icons.
  • Ensure Privacy answers accurately reflect actual code behavior.
  • Provide explicit test instructions and credentials if your extension requires login.
  • Run the closed-DevTools shutdown test.

How do you publish a Chrome extension to the Web Store?

Zip the built extension, upload it in the Chrome Developer Dashboard, and accurately complete the store listing and privacy tabs. Include test instructions if reviewers need credentials. Tighten constraints pre-upload, as broad host permissions or sensitive APIs slow down the approval process.

What to build after v1: options page, side panel, and sustainable monetization

Expand beyond the popup only after perfecting the core architecture and proving user utility.

Add an options page for real settings

When configuration settings outgrow the ephemeral popup, declare an options_ui in the manifest. Move lengthy form steps and persistent user preferences into this dedicated React route.

Use the Side Panel API when the UI should stay open

The Side Panel API hosts extension UI directly beside the webpage layout. It persists naturally across tabs. This makes it better suited than popups for heavy note-taking, AI chat companions, or constant-reference tooling.

Add monetization only after utility and trust are clear

Once your extension secures real user adoption, evaluate paid upgrades or sponsorships. If you intend to keep the software free while generating a non-ad revenue stream, look into consent-based monetization platforms like Mellowtel.

Transparent Mellowtel integration

Mellowtel enables developers to earn revenue by allowing users to share a fraction of unused internet bandwidth. The platform requires strict adherence to privacy and consent. The documentation demands explicit opt-in before any sharing occurs, highly visible opt-out paths, and Manifest V3 compiled setups. Mellowtel also provides an optional Mellowtel Elements package featuring prebuilt, compliant consent UIs. Always review browser policy compliance guides to ensure your implementation aligns with single-purpose rules.

How can you monetize a free Chrome extension without ads?

Evaluate monetization models that align directly with product utility and user trust. Platforms like Mellowtel offer non-ad revenue via bandwidth sharing but strictly require explicit user opt-in, clear opt-out mechanics, and transparent settings. Treat monetization as a carefully planned post-launch architecture decision.

FAQ

Can I use Next.js for a Chrome extension?

Yes, but it adds unnecessary architectural friction. Extension pages remain static manifest-defined assets, and traditional server-side web routing fails entirely. Use React with Vite/CRXJS for direct platform alignment. If you already maintain a Next.js app, share components rather than forcing the web framework into the extension runtime.

Do I need a React Chrome extension boilerplate?

No. Boilerplates hide the setup, but you still must understand the underlying MV3 pieces to debug effectively. Using a lightweight Vite and CRXJS path ensures you learn the actual platform architecture rather than inheriting bloated abstractions you do not fully control.

Can React run in the service worker?

Not in the traditional UI sense. The service worker operates as an event-handling background script without DOM access, making React entirely the wrong tool for it. Render React strictly in your visible injected pages or popups. Keep background tasks in plain TypeScript modules.

Why does my popup lose state every time I open it?

A popup acts as ephemeral UI. Chrome terminates it the moment a user clicks elsewhere, aggressively destroying the React tree and associated local state. Store session keys, draft data, and preferences inside chrome.storage, then explicitly rehydrate the popup UI upon mount.

Why do my injected React styles break on some websites?

Your injected React code enters an external DOM environment. Aggressive global CSS cascades from the host site will alter your layout. To fix this, mount the React root into a Shadow Root, strictly isolating your component styles from host page interference.