← Back to articles

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

  1. Create a developer account ($5 one-time fee)
  2. Zip the dist/ folder
  3. Upload to Chrome Web Store Developer Dashboard
  4. Add screenshots, description, and category
  5. 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.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.