Building Chrome Extensions with React and TypeScript
Chrome extensions are one of the highest-leverage products you can build. They reach users in their browser, integrate with existing workflows without requiring users to switch applications, and distribute through the Chrome Web Store to 3 billion Chrome users. Despite this enormous opportunity, the developer experience for building extensions is stuck in 2015. The official documentation is fragmented across dozens of pages, the APIs are callback-based relics from before Promises existed, and most tutorials use vanilla JavaScript with no build tooling.
At Harbor Software, we have built several Chrome extensions for internal tools and client products. We have evolved our approach to use React, TypeScript, and modern build tooling that makes the development experience pleasant and the code maintainable. Here is the complete guide to building Chrome extensions the modern way.
Chrome Extension Architecture: The Four Contexts
A Chrome extension has up to four distinct execution contexts, each running in its own isolated JavaScript environment with its own capabilities and restrictions. Understanding these contexts is the foundation of extension development. You cannot build a reliable extension without understanding where your code runs and what it can access.
- Popup – The UI that appears when you click the extension icon in the toolbar. This is a full HTML page rendered in a small window (typically 300-600px wide). It has access to Chrome APIs but cannot interact with the page’s DOM. It is destroyed when the popup closes, so it has no persistent state of its own.
- Content Script – JavaScript injected into web pages that the user visits. It can read and modify the page’s DOM, intercept network requests, and observe user interactions. It runs in an isolated world with its own JavaScript scope, meaning it cannot access the page’s JavaScript variables. It has limited Chrome API access.
- Background Script (Service Worker) – A persistent-ish script that handles events, manages state, and coordinates between popup and content scripts. In Manifest V3, this is a service worker with a limited lifetime (it goes idle after ~30 seconds of inactivity and restarts on events). This means you cannot rely on in-memory state.
- Options Page – A full-page settings UI for the extension. Same capabilities as the popup but rendered as a regular browser tab with more space.
These contexts communicate via Chrome’s messaging API. Understanding this architecture is essential because React components in the popup cannot directly call functions in the content script. Everything goes through asynchronous message passing. This is similar to a microservices architecture: each context is an independent process with well-defined communication interfaces.
Project Setup with Vite
We use Vite as our build tool because it handles multiple entry points natively, which maps perfectly to the multi-context architecture of Chrome extensions. Each context gets its own entry point, its own bundle, and its own output directory.
extension/
├── manifest.json # Extension manifest (V3)
├── vite.config.ts # Build configuration
├── tsconfig.json # TypeScript configuration
├── package.json
├── src/
│ ├── popup/ # Popup UI (React)
│ │ ├── index.html
│ │ ├── main.tsx
│ │ ├── App.tsx
│ │ └── components/
│ │ ├── Header.tsx
│ │ ├── Results.tsx
│ │ └── Settings.tsx
│ ├── content/ # Content script (DOM manipulation)
│ │ ├── index.ts
│ │ ├── overlay.tsx # React component injected into pages
│ │ └── styles.css
│ ├── background/ # Service worker
│ │ ├── index.ts
│ │ └── handlers.ts
│ ├── shared/ # Code shared across all contexts
│ │ ├── types.ts
│ │ ├── messages.ts
│ │ ├── storage.ts
│ │ └── constants.ts
│ └── options/ # Options page (React)
│ ├── index.html
│ └── main.tsx
└── public/
└── icons/
├── icon-16.png
├── icon-48.png
└── icon-128.png
The Vite configuration handles the multiple entry points:
// 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'),
options: resolve(__dirname, 'src/options/index.html'),
background: resolve(__dirname, 'src/background/index.ts'),
content: resolve(__dirname, 'src/content/index.ts'),
},
output: {
entryFileNames: '[name]/index.js',
chunkFileNames: 'shared/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
},
},
outDir: 'dist',
emptyOutDir: true,
// Content scripts cannot use ES modules in some browsers
// Use IIFE format for content script output
target: 'es2020',
},
// Resolve Chrome API types
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},
});
Install the Chrome types for TypeScript support: npm install -D @types/chrome. This gives you autocomplete and type checking for all Chrome APIs.
The Manifest (V3)
Manifest V3 is the current standard and the only format accepted for new Chrome Web Store submissions as of 2023. Key differences from V2: background pages are replaced with service workers (which have limited lifetimes), remote code execution is completely blocked (no eval, no loading scripts from external URLs), and permissions are more granular with optional permissions for better user trust.
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A Chrome extension built with React and TypeScript",
"permissions": ["storage", "activeTab"],
"optional_permissions": ["tabs", "bookmarks"],
"action": {
"default_popup": "popup/index.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"background": {
"service_worker": "background/index.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content/index.js"],
"css": ["content/styles.css"],
"run_at": "document_idle"
}
],
"options_page": "options/index.html",
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
Permission best practices: Request the minimum permissions needed. Use activeTab instead of tabs when you only need access to the current tab. Use optional_permissions for features that are not needed by all users, and request them at runtime when the user activates that feature. The Chrome Web Store review team scrutinizes permissions, and excessive permissions increase rejection risk and decrease user trust.
Type-Safe Messaging Between Contexts
The messaging layer between extension contexts is where most bugs live. Chrome’s messaging API is untyped and callback-based. Messages are just objects with no compile-time guarantee that the sender and receiver agree on the shape. We wrap it with TypeScript generics to get compile-time safety:
// src/shared/messages.ts
// Define all message types as a discriminated union
// This is the single source of truth for all inter-context communication
type Message =
| { type: 'GET_PAGE_DATA'; payload: { url: string } }
| { type: 'PAGE_DATA_RESULT'; payload: { title: string; content: string; wordCount: number } }
| { type: 'SAVE_SETTINGS'; payload: { apiKey: string; enabled: boolean; theme: 'light' | 'dark' } }
| { type: 'ANALYZE_TEXT'; payload: { text: string; mode: 'summary' | 'sentiment' | 'translate' } }
| { type: 'ANALYSIS_RESULT'; payload: { result: string; confidence: number; model: string } }
| { type: 'ERROR'; payload: { code: string; message: string } };
// Extract payload type for a given message type
type PayloadOf = Extract['payload'];
// Type-safe message sender
export function sendMessage(
type: T,
payload: PayloadOf
): Promise {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(response);
}
});
});
}
// Type-safe message sender to content script in a specific tab
export function sendToTab(
tabId: number,
type: T,
payload: PayloadOf
): Promise {
return new Promise((resolve, reject) => {
chrome.tabs.sendMessage(tabId, { type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(response);
}
});
});
}
// Type-safe message handler registration
export function onMessage(
type: T,
handler: (
payload: PayloadOf,
sender: chrome.runtime.MessageSender
) => Promise | any
): void {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === type) {
const result = handler(message.payload, sender);
if (result instanceof Promise) {
result
.then(sendResponse)
.catch(err => sendResponse({ error: err.message }));
return true; // Keep message channel open for async response
} else {
sendResponse(result);
}
}
});
}
The discriminated union pattern ensures that if you add a new message type, TypeScript will enforce that both senders and handlers match the new type’s payload shape. If you mistype a field name or pass the wrong type, you get a compile-time error instead of a runtime mystery.
Usage in the background script:
// src/background/index.ts
import { onMessage } from '../shared/messages';
onMessage('ANALYZE_TEXT', async ({ text, mode }) => {
// TypeScript knows text is string and mode is 'summary' | 'sentiment' | 'translate'
const response = await fetch('https://api.example.com/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mode }),
});
if (!response.ok) {
return { error: `API returned ${response.status}` };
}
return response.json();
});
onMessage('SAVE_SETTINGS', async ({ apiKey, enabled, theme }) => {
await chrome.storage.sync.set({ apiKey, enabled, theme });
return { success: true };
});
State Management with Chrome Storage
Extension state lives in chrome.storage, not React state or localStorage. Chrome storage syncs across devices (with chrome.storage.sync), persists across browser restarts, persists across service worker restarts (critical in MV3), and is accessible from all extension contexts simultaneously.
We wrap it in a React hook that provides reactive updates:
// src/shared/storage.ts
import { useState, useEffect, useCallback } from 'react';
export function useChromeStorage(
key: string,
defaultValue: T
): [T, (value: T | ((prev: T) => T)) => void, boolean] {
const [value, setValue] = useState(defaultValue);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Load initial value
chrome.storage.sync.get(key, (result) => {
if (result[key] !== undefined) {
setValue(result[key] as T);
}
setLoading(false);
});
// Listen for changes from other contexts (popup, background, options)
const listener = (
changes: { [key: string]: chrome.storage.StorageChange },
areaName: string
) => {
if (areaName === 'sync' && changes[key]) {
setValue(changes[key].newValue as T);
}
};
chrome.storage.onChanged.addListener(listener);
return () => chrome.storage.onChanged.removeListener(listener);
}, [key]);
const setStoredValue = useCallback((newValue: T | ((prev: T) => T)) => {
const resolved = typeof newValue === 'function'
? (newValue as (prev: T) => T)(value)
: newValue;
chrome.storage.sync.set({ [key]: resolved });
setValue(resolved);
}, [key, value]);
return [value, setStoredValue, loading];
}
// Usage in a component:
function SettingsPanel() {
const [settings, setSettings, loading] = useChromeStorage('settings', {
apiKey: '',
enabled: true,
theme: 'light' as const
});
if (loading) return Loading...;
return (
);
}
The onChanged listener is critical. It ensures that when the background script updates a setting, the popup UI reflects the change immediately without manual refresh. This cross-context reactivity is one of the main benefits of using chrome.storage over other state management approaches.
Content Script CSS Isolation with Shadow DOM
When your content script injects UI into a webpage, the page’s CSS will collide with your styles. This is the most annoying practical problem in extension development. The host page might have a global * { box-sizing: border-box; } that changes your layout, or a div { margin: 20px; } that spaces your elements incorrectly, or a CSS reset that removes your list styles. The solution is Shadow DOM:
// src/content/index.ts
import { createRoot } from 'react-dom/client';
import ContentOverlay from './overlay';
function injectUI() {
// Create a host element with a unique ID to avoid collisions
const host = document.createElement('div');
host.id = 'harbor-extension-root';
// Attach shadow DOM in closed mode (page JS cannot access our DOM)
const shadow = host.attachShadow({ mode: 'closed' });
// Inject styles into the shadow DOM
const style = document.createElement('style');
style.textContent = `
:host {
all: initial; /* Reset all inherited styles */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.overlay {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 2147483647; /* Max z-index to stay on top */
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
padding: 16px;
width: 320px;
max-height: 400px;
overflow-y: auto;
}
.overlay h3 {
margin: 0 0 12px;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.overlay button {
background: #2563eb;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.15s;
}
.overlay button:hover {
background: #1d4ed8;
}
`;
shadow.appendChild(style);
// Create React mount point inside shadow DOM
const mountPoint = document.createElement('div');
shadow.appendChild(mountPoint);
document.body.appendChild(host);
// Render React into the shadow DOM
const root = createRoot(mountPoint);
root.render( );
}
// Inject when the page is ready
if (document.readyState === 'complete') {
injectUI();
} else {
window.addEventListener('load', injectUI);
}
Shadow DOM provides complete CSS isolation. The page’s styles cannot leak in, and your styles cannot leak out. The :host { all: initial; } rule is essential because it resets all inherited properties (color, font-size, line-height, etc.) to their initial values, ensuring your UI looks consistent across every website regardless of their CSS.
Development Workflow and Hot Reload
The development loop for Chrome extensions is slower than web development because you need to reload the extension after every change. Here is how we streamline it to minimize friction:
- Use Vite in watch mode (
vite build --watch) to rebuild on file changes automatically. - Use a keyboard shortcut to reload the extension. Go to chrome://extensions, enable Developer mode, and note the extension’s ID. Then use
chrome.runtime.reload()from the background script console. - Develop popup UI in a regular browser tab by opening
popup/index.htmldirectly. Mock the Chrome APIs for rapid iteration on the visual design. Switch to the real extension only for integration testing. - Use console.log strategically in each context. The popup has its own DevTools (right-click the popup, Inspect). The background script logs appear in the service worker console (chrome://extensions, click “Inspect views: service worker”). Content script logs appear in the page’s DevTools console.
// Chrome API mocking for development in a regular browser tab
const isDevelopment = typeof chrome === 'undefined' || !chrome.runtime?.id;
const mockStorage: Record = {};
export const storage = isDevelopment
? {
get: (key: string, callback: Function) =>
callback({ [key]: mockStorage[key] }),
set: (data: Record, callback?: Function) => {
Object.assign(mockStorage, data);
callback?.();
},
}
: chrome.storage.sync;
Publishing to the Chrome Web Store
The Chrome Web Store review process takes 1-5 business days. Plan for this in your release cycle. Common rejection reasons we have encountered:
- Excessive permissions. Request only the permissions you need.
activeTabis preferred over broad host permissions. Avoid<all_urls>if you only need specific domains. The review team flags unnecessary permissions aggressively. - Missing privacy policy. Required for any extension that handles user data (which is almost every extension). A simple privacy policy page on your website is sufficient.
- Unclear description. The store listing must clearly explain what the extension does and why it needs each permission. Vague descriptions get rejected.
- Single-purpose policy. Each extension should do one thing. Multi-purpose extensions get rejected.
- Remote code loading. Manifest V3 blocks all remote code execution. If your build process loads external scripts, you will be rejected.
// package.json scripts for build and packaging
{
"scripts": {
"dev": "vite build --watch --mode development",
"build": "vite build --mode production",
"package": "npm run build && cd dist && zip -r ../extension-v$(node -e "console.log(require('../package.json').version)").zip . && cd ..",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit"
}
}
Conclusion
Chrome extensions built with React and TypeScript are maintainable, testable, and pleasant to develop once the initial setup is done. The key architectural decisions are: understand the four execution contexts, build a type-safe messaging layer, isolate content script CSS with Shadow DOM, and use chrome.storage for cross-context reactive state management.
The Chrome extension platform has rough edges (the MV3 service worker lifecycle, the reload-heavy development workflow, the review process), but the distribution advantage is enormous. Three billion Chrome users, one-click install, no app store download friction, and deep integration with the user’s existing workflow. For developer tools, productivity apps, and workflow integrations, a Chrome extension is often the fastest path to users. Build it properly with modern tooling and you will have a codebase that scales with your product as it grows from 10 users to 100,000.