How to Build a Chrome Extension with React and TypeScript (2026)
Chrome extensions are one of the best distribution channels for developer tools and productivity apps. 200,000+ extensions serve billions of users, and building one with React and TypeScript in 2026 is surprisingly straightforward.
This guide covers everything from project setup to Chrome Web Store publishing.
Prerequisites
- Node.js 18+
- Basic React and TypeScript knowledge
- A Chrome browser
Project Setup
The fastest way to start is with CRXJS (Chrome Extension + Vite) or Plasmo. Both handle the Manifest V3 boilerplate.
Option A: Plasmo (Recommended for Beginners)
Plasmo is a framework for building Chrome extensions with React. It handles bundling, hot reload, and manifest generation.
npm create plasmo@latest my-extension
cd my-extension
npm run dev
Plasmo generates:
popup.tsx— your extension's popup UI (React component)package.json— with Plasmo as the build tool- Manifest V3 — auto-generated from your file structure
Option B: CRXJS + Vite (More Control)
CRXJS is a Vite plugin that adds Chrome extension support.
npm create vite@latest my-extension -- --template react-ts
cd my-extension
npm install @crxjs/vite-plugin -D
Create manifest.json:
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A Chrome extension built with React",
"action": {
"default_popup": "index.html",
"default_icon": "icon.png"
},
"permissions": ["storage", "activeTab"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content.tsx"]
}
]
}
Update vite.config.ts:
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 })],
});
Chrome Extension Architecture
A Chrome extension has up to four contexts:
1. Popup (Your Main UI)
The popup appears when users click your extension icon. It's a regular React app with some limitations (no direct DOM access to the page).
// popup.tsx
import { useState, useEffect } from 'react';
function Popup() {
const [count, setCount] = useState(0);
useEffect(() => {
// Load saved state
chrome.storage.local.get(['count'], (result) => {
setCount(result.count || 0);
});
}, []);
const increment = () => {
const newCount = count + 1;
setCount(newCount);
chrome.storage.local.set({ count: newCount });
};
return (
<div style={{ width: 300, padding: 16 }}>
<h1>My Extension</h1>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
export default Popup;
2. Content Script (Inject into Web Pages)
Content scripts run in the context of web pages. They can read and modify the DOM.
// content.tsx
import { createRoot } from 'react-dom/client';
// Create a container for your React UI
const container = document.createElement('div');
container.id = 'my-extension-root';
document.body.appendChild(container);
function ContentApp() {
return (
<div style={{
position: 'fixed',
bottom: 20,
right: 20,
zIndex: 99999,
background: 'white',
padding: 16,
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}>
<p>Hello from my extension!</p>
</div>
);
}
createRoot(container).render(<ContentApp />);
3. Background Script (Service Worker)
Runs in the background, handles events, and manages state. No DOM access.
// background.ts
chrome.runtime.onInstalled.addListener(() => {
console.log('Extension installed');
chrome.storage.local.set({ count: 0 });
});
// Listen for messages from popup or content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_DATA') {
// Fetch data, process it, etc.
sendResponse({ data: 'Hello from background' });
}
return true; // Keep message channel open for async response
});
4. Options Page (Settings)
A full page for extension settings. Standard React page.
Communication Between Contexts
Popup ↔ Background
// From popup: send message to background
const response = await chrome.runtime.sendMessage({ type: 'GET_DATA' });
// In background: listen and respond
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_DATA') {
sendResponse({ result: 'data here' });
}
return true;
});
Content Script ↔ Background
// From content script
chrome.runtime.sendMessage({ type: 'PAGE_DATA', url: window.location.href });
// From background to content script
chrome.tabs.sendMessage(tabId, { type: 'UPDATE_UI', data: newData });
Shared Storage
// Write (from any context)
await chrome.storage.local.set({ key: 'value' });
// Read (from any context)
const result = await chrome.storage.local.get(['key']);
console.log(result.key);
// Listen for changes (from any context)
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.key) {
console.log('Key changed:', changes.key.newValue);
}
});
Styling
CSS Modules
Works out of the box with Vite/Plasmo:
import styles from './Popup.module.css';
function Popup() {
return <div className={styles.container}>...</div>;
}
Tailwind CSS
Install and configure as usual. For content scripts, use Shadow DOM to avoid style conflicts with the host page:
// Content script with Shadow DOM
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
document.body.appendChild(host);
// Add Tailwind styles to Shadow DOM
const style = document.createElement('style');
style.textContent = tailwindCSS; // Your compiled Tailwind CSS
shadow.appendChild(style);
const root = document.createElement('div');
shadow.appendChild(root);
createRoot(root).render(<ContentApp />);
Common Patterns
Fetching Data from APIs
// In background script (avoids CORS issues)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'FETCH_API') {
fetch(message.url)
.then(res => res.json())
.then(data => sendResponse({ data }))
.catch(err => sendResponse({ error: err.message }));
return true; // Async response
}
});
Keyboard Shortcuts
In manifest.json:
{
"commands": {
"_execute_action": {
"suggested_key": { "default": "Ctrl+Shift+Y" },
"description": "Open extension"
},
"toggle-feature": {
"suggested_key": { "default": "Ctrl+Shift+U" },
"description": "Toggle feature"
}
}
}
Context Menu
// background.ts
chrome.contextMenus.create({
id: 'my-action',
title: 'Do something with "%s"',
contexts: ['selection'],
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'my-action') {
console.log('Selected text:', info.selectionText);
}
});
Manifest V3 Key Points
- Service Workers replace background pages (no persistent background)
- chrome.action replaces chrome.browserAction and chrome.pageAction
- Content Security Policy is stricter (no inline scripts, no eval)
- Remote code is not allowed (all code must be bundled)
- Promises are now supported for most Chrome APIs (no more callbacks)
Testing
Manual Testing
- Run
npm run dev(with Plasmo or CRXJS, hot reload works) - Go to
chrome://extensions/ - Enable "Developer mode"
- Click "Load unpacked" → select your
distorbuildfolder - Test popup, content scripts, and background
Automated Testing
// Use Vitest for unit tests
import { describe, it, expect, vi } from 'vitest';
// Mock Chrome APIs
const mockChrome = {
storage: {
local: {
get: vi.fn(),
set: vi.fn(),
},
},
};
global.chrome = mockChrome as any;
describe('Popup', () => {
it('loads saved count', async () => {
mockChrome.storage.local.get.mockImplementation((keys, cb) => {
cb({ count: 42 });
});
// Test your component...
});
});
Publishing to Chrome Web Store
Prepare Assets
- Icon: 128x128px PNG
- Screenshots: 1280x800px or 640x400px (at least 1, up to 5)
- Promotional images: 440x280px (small), 920x680px (large)
- Description: Clear, keyword-rich (up to 132 characters for short description)
Build and Package
npm run build
# Creates a dist/ or build/ folder
# Zip the contents (not the folder itself)
cd dist && zip -r ../extension.zip .
Submit
- Go to Chrome Web Store Developer Dashboard
- Pay one-time $5 developer fee
- Upload your .zip file
- Fill in listing details (description, screenshots, category)
- Submit for review (typically 1-3 days)
Review Tips
- Clearly explain what permissions your extension needs and why
- Don't request unnecessary permissions
- Include a privacy policy if you collect any data
- Respond promptly to reviewer questions
Monetization Options
- Freemium: Free extension with paid features (use Stripe for payments)
- One-time purchase: Sell through your own website, deliver a license key
- Subscription: Monthly access to premium features
- Sponsorship: If your extension has significant users, sponsors may be interested
FAQ
How long does Chrome Web Store review take?
Typically 1-3 business days for new extensions. Updates are usually faster (hours to 1 day).
Can I use the same extension for Firefox?
With minor modifications. Both Chrome and Firefox use WebExtensions API. Plasmo supports multi-browser builds. Key differences: Firefox uses browser.* (with promises) while Chrome uses chrome.* (mostly promises in Manifest V3).
How do I handle updates?
Update the version in manifest.json, rebuild, and upload a new zip to the Chrome Web Store. Users receive updates automatically.
What's the maximum extension size?
Chrome Web Store allows extensions up to 500MB, but smaller is better for user adoption. Most extensions are under 5MB.
The Bottom Line
Building a Chrome extension with React in 2026:
- Use Plasmo for the fastest start (or CRXJS for more control)
- Manifest V3 is the only option — embrace service workers
- Shadow DOM for content scripts to avoid style conflicts
- Background script for API calls and event handling
- Chrome Storage API for shared state between contexts
Start with a popup-only extension, add content scripts when you need page interaction, and add a background script for persistent logic. Ship to the Chrome Web Store for $5 and iterate from there.