How to Build a Chrome Extension with React (2026)
Chrome extensions with React give you a modern UI toolkit for popups, sidepanels, and content scripts. Here's the complete guide using Manifest V3, Vite, and React.
Project Setup
npm create vite@latest my-extension -- --template react-ts
cd my-extension
npm install
Project Structure
my-extension/
├── public/
│ ├── manifest.json # Extension manifest
│ ├── icons/ # Extension icons
│ └── content.css # Content script styles
├── src/
│ ├── popup/ # Popup UI (React)
│ │ ├── Popup.tsx
│ │ ├── main.tsx
│ │ └── index.html
│ ├── background/ # Service worker
│ │ └── index.ts
│ ├── content/ # Content script
│ │ └── index.ts
│ └── shared/ # Shared utilities
│ ├── storage.ts
│ └── messaging.ts
├── vite.config.ts
└── package.json
Manifest V3
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A Chrome extension built with React",
"permissions": ["storage", "activeTab", "tabs"],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": ["content.css"]
}
],
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
Popup (React UI)
// src/popup/Popup.tsx
import { useState, useEffect } from 'react'
export function Popup() {
const [count, setCount] = useState(0)
const [currentUrl, setCurrentUrl] = useState('')
useEffect(() => {
// Load saved count
chrome.storage.local.get(['count'], (result) => {
setCount(result.count || 0)
})
// Get current tab URL
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
setCurrentUrl(tabs[0]?.url || '')
})
}, [])
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>Current page: {currentUrl}</p>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
)
}
// src/popup/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Popup } from './Popup'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Popup />
</React.StrictMode>
)
Background Service Worker
// src/background/index.ts
// Listen for extension install
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_PAGE_DATA') {
// Process and return data
sendResponse({ status: 'ok', data: message.data })
}
return true // Keep message channel open for async response
})
// Context menu
chrome.contextMenus?.create({
id: 'my-extension-action',
title: 'Do something with "%s"',
contexts: ['selection'],
})
chrome.contextMenus?.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'my-extension-action') {
console.log('Selected text:', info.selectionText)
}
})
Content Script
// src/content/index.ts
// Inject UI into the page
function injectUI() {
const container = document.createElement('div')
container.id = 'my-extension-root'
document.body.appendChild(container)
// You can render React here too
container.innerHTML = `
<div style="position: fixed; bottom: 20px; right: 20px; z-index: 99999;
background: white; padding: 16px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<p>Extension is active on this page</p>
<button id="my-ext-btn">Click me</button>
</div>
`
document.getElementById('my-ext-btn')?.addEventListener('click', () => {
// Send message to background script
chrome.runtime.sendMessage({ type: 'BUTTON_CLICKED', url: window.location.href })
})
}
injectUI()
// Listen for messages from background/popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'HIGHLIGHT_TEXT') {
// Manipulate the page DOM
document.querySelectorAll('p').forEach(p => {
p.style.backgroundColor = 'yellow'
})
sendResponse({ status: 'done' })
}
})
Storage API
// src/shared/storage.ts
// Save data
export async function saveData(key: string, value: any) {
return chrome.storage.local.set({ [key]: value })
}
// Load data
export async function loadData<T>(key: string): Promise<T | undefined> {
const result = await chrome.storage.local.get([key])
return result[key]
}
// Watch for changes
export function watchStorage(key: string, callback: (newValue: any) => void) {
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes[key]) {
callback(changes[key].newValue)
}
})
}
Messaging Between Components
// src/shared/messaging.ts
// Send message from popup to background
export async function sendToBackground(message: any) {
return chrome.runtime.sendMessage(message)
}
// Send message from popup/background to content script
export async function sendToContentScript(tabId: number, message: any) {
return chrome.tabs.sendMessage(tabId, message)
}
Vite Config for Extension
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: {
popup: resolve(__dirname, 'src/popup/index.html'),
background: resolve(__dirname, 'src/background/index.ts'),
content: resolve(__dirname, 'src/content/index.ts'),
},
output: {
entryFileNames: '[name].js',
},
},
outDir: 'dist',
},
})
Build and Load
# Build
npm run build
# Load in Chrome:
# 1. Go to chrome://extensions
# 2. Enable "Developer mode"
# 3. Click "Load unpacked"
# 4. Select the dist/ folder
Publishing to Chrome Web Store
- Create a developer account ($5 one-time fee)
- Zip the
dist/folder - Upload to Chrome Web Store Developer Dashboard
- Add screenshots, description, and category
- Submit for review (1-3 days)
Common Patterns
API Calls from Extension
// Background script (not content script — avoids CORS)
chrome.runtime.onMessage.addListener(async (message, sender, sendResponse) => {
if (message.type === 'API_CALL') {
const response = await fetch('https://api.example.com/data')
const data = await response.json()
sendResponse({ data })
}
return true
})
Badge Counter
chrome.action.setBadgeText({ text: '5' })
chrome.action.setBadgeBackgroundColor({ color: '#FF0000' })
FAQ
Can I use Tailwind CSS?
Yes. Install Tailwind and configure it for the popup. Content script styles should be scoped to avoid conflicts with page styles.
How do I debug?
- Popup: right-click the popup → "Inspect"
- Background: chrome://extensions → "Inspect views: service worker"
- Content script: regular browser DevTools console
Can I use npm packages?
Yes, in popup and background scripts. Content scripts have limitations — large libraries increase page load time.
Bottom Line
Chrome extensions with React + Vite give you a modern development experience. The key pieces: manifest.json for configuration, popup for UI, background for logic, content scripts for page interaction. Build with npm run build, load unpacked for testing, publish to Chrome Web Store when ready.