onezlabs

0%

Browser Extensions

How to Build a Chrome Extension with React and TypeScript

A complete walkthrough for building modern Chrome extensions with React, TypeScript, and Vite — from Manifest V3 setup to Chrome Web Store submission.

June 30, 202613 min read
#Chrome Extension#React#TypeScript#Vite#Manifest V3#Browser Extension
How to Build a Chrome Extension with React and TypeScript

Project Setup with Vite and TypeScript

Chrome extensions with React and TypeScript used to require complex webpack configurations. Modern tooling — specifically Vite with the CRXJS plugin — reduces this to a clean, fast development environment. I use this setup for all my Chrome extension projects.

# Create the project
npm create vite@latest my-extension -- --template react-ts
cd my-extension

# Install CRXJS for seamless Chrome extension development with Vite
npm install -D @crxjs/vite-plugin

# Core extension dependencies
npm install webextension-polyfill
npm install -D @types/chrome
// 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 }),
  ],
  server: {
    port: 5173,
    strictPort: true,
    hmr: {
      port: 5173,
    },
  },
});

Manifest V3 Configuration

The manifest.json is the declaration file for your extension — it specifies permissions, entry points, and Chrome API access. Every permission you request shows up on the install confirmation screen, so request only what you genuinely need.

{
  "manifest_version": 3,
  "name": "My React Extension",
  "version": "1.0.0",
  "description": "A Chrome extension built with React and TypeScript.",
  "icons": {
    "16": "icons/icon16.png",
    "32": "icons/icon32.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "action": {
    "default_popup": "src/popup/index.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "32": "icons/icon32.png"
    },
    "default_title": "My Extension"
  },
  "background": {
    "service_worker": "src/background/index.ts",
    "type": "module"
  },
  "content_scripts": [
    {
      "matches": [""],
      "js": ["src/content/index.ts"],
      "run_at": "document_idle"
    }
  ],
  "side_panel": {
    "default_path": "src/sidepanel/index.html"
  },
  "options_ui": {
    "page": "src/options/index.html",
    "open_in_tab": true
  },
  "permissions": [
    "activeTab",
    "storage",
    "sidePanel",
    "contextMenus"
  ],
  "host_permissions": []
}

The popup is a standard React application rendered in a small browser window. Keep popup UX simple — users interact briefly with popups. Complex workflows belong in the side panel or options page.

// src/popup/index.html — minimal HTML shell
// CRXJS handles HMR and script injection automatically

// src/popup/App.tsx
import { useState, useEffect } from "react";
import { getCurrentTab, sendMessageToTab } from "../lib/chrome-helpers";

interface TabInfo {
  title: string;
  url: string;
}

export function App() {
  const [tabInfo, setTabInfo] = useState<TabInfo | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    getCurrentTab().then((tab) => {
      if (tab) {
        setTabInfo({ title: tab.title || "", url: tab.url || "" });
      }
      setIsLoading(false);
    });
  }, []);

  const handleAnalyze = async () => {
    const tab = await getCurrentTab();
    if (!tab?.id) return;

    const response = await sendMessageToTab(tab.id, { type: "ANALYZE_PAGE" });
    console.log("Analysis result:", response);
  };

  if (isLoading) return <div className="popup-loading">Loading...</div>;

  return (
    <div className="popup-container">
      <header>
        <h1>My Extension</h1>
      </header>
      <main>
        <p className="tab-title">{tabInfo?.title}</p>
        <button onClick={handleAnalyze} className="btn-primary">
          Analyze Page
        </button>
      </main>
    </div>
  );
}
// src/lib/chrome-helpers.ts — typed wrappers around Chrome APIs
export async function getCurrentTab(): Promise<chrome.tabs.Tab | null> {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  return tab || null;
}

export async function sendMessageToTab<T = unknown>(
  tabId: number,
  message: Record<string, unknown>
): Promise<T> {
  return chrome.tabs.sendMessage(tabId, message);
}

export async function sendMessageToBackground<T = unknown>(
  message: Record<string, unknown>
): Promise<T> {
  return chrome.runtime.sendMessage(message);
}

Content Scripts and DOM Interaction

Content scripts run in the context of the web page. They can read and modify the DOM, but they cannot use most Chrome APIs directly (no chrome.storage, no chrome.downloads — those are service worker APIs). Content scripts communicate with the service worker through message passing.

// src/content/index.ts
// Content scripts cannot use ES module imports in the traditional sense —
// CRXJS handles bundling them into a single file automatically.

interface PageData {
  title: string;
  headings: { level: string; text: string }[];
  wordCount: number;
  links: { text: string; href: string }[];
}

function extractPageData(): PageData {
  const headings = Array.from(document.querySelectorAll("h1, h2, h3"))
    .slice(0, 20)
    .map((el) => ({
      level: el.tagName,
      text: el.textContent?.trim() || "",
    }));

  const wordCount = document.body.innerText
    .split(/s+/)
    .filter(Boolean).length;

  const links = Array.from(document.querySelectorAll("a[href]"))
    .slice(0, 50)
    .map((el) => ({
      text: el.textContent?.trim() || "",
      href: (el as HTMLAnchorElement).href,
    }))
    .filter((link) => link.text && link.href.startsWith("http"));

  return {
    title: document.title,
    headings,
    wordCount,
    links,
  };
}

// Inject a UI element into the page
function injectOverlay() {
  if (document.getElementById("my-extension-overlay")) return;

  const overlay = document.createElement("div");
  overlay.id = "my-extension-overlay";
  overlay.style.cssText = `
    position: fixed;
    bottom: 20px;
    right: 20px;
    z-index: 2147483647;
    background: white;
    border: 1px solid #e2e8f0;
    border-radius: 8px;
    padding: 12px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.15);
    font-family: system-ui, sans-serif;
    font-size: 14px;
  `;
  document.body.appendChild(overlay);
}

// Listen for messages from popup or service worker
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message.type === "GET_PAGE_DATA") {
    sendResponse(extractPageData());
  }

  if (message.type === "INJECT_OVERLAY") {
    injectOverlay();
    sendResponse({ success: true });
  }

  return true; // Keep message channel open for async responses
});

Background Service Worker

The service worker handles background tasks, coordinates between content scripts and popup, and manages any API calls. It can be terminated by Chrome when idle — never store important state in memory only.

// src/background/index.ts
chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === "install") {
    // Set default settings on first install
    chrome.storage.local.set({
      settings: {
        autoAnalyze: false,
        showOverlay: true,
        theme: "system",
      },
    });

    // Open options page on first install
    chrome.runtime.openOptionsPage();
  }

  // Create context menu items
  chrome.contextMenus.create({
    id: "analyze-selection",
    title: "Analyze with My Extension",
    contexts: ["selection"],
  });
});

// Handle context menu clicks
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId === "analyze-selection" && tab?.id) {
    const selectedText = info.selectionText || "";

    // Open side panel with the selected text
    await chrome.sidePanel.open({ windowId: tab.windowId });

    // Send the selected text to the side panel
    chrome.runtime.sendMessage({
      type: "ANALYZE_TEXT",
      text: selectedText,
      tabId: tab.id,
    });
  }
});

// Handle messages from popup and content scripts
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message.type === "GET_SETTINGS") {
    chrome.storage.local.get("settings").then(({ settings }) => {
      sendResponse(settings);
    });
    return true;
  }
});

Cross-Context Messaging

Chrome extension contexts (popup, service worker, content script, side panel) cannot share memory. All communication goes through chrome.runtime.sendMessage (for popup↔service worker and service worker↔popup) and chrome.tabs.sendMessage (for service worker↔content script).

Common gotcha: chrome.tabs.sendMessage fails if no content script is loaded on the target tab. Always wrap tab messages in a try-catch and handle the "Could not establish connection" error gracefully — it is expected behavior when the extension has not yet injected into the current tab.

Storage and State Management

Use chrome.storage.local for data that should persist across service worker restarts but does not need to sync across devices. Use chrome.storage.sync for user preferences that should sync across the user's Chrome instances (limited to 100KB total).

// src/lib/storage.ts — typed storage helpers
interface ExtensionSettings {
  autoAnalyze: boolean;
  showOverlay: boolean;
  theme: "light" | "dark" | "system";
  apiKey?: string;
}

export async function getSettings(): Promise<ExtensionSettings> {
  const { settings } = await chrome.storage.local.get("settings");
  return settings as ExtensionSettings;
}

export async function updateSettings(
  updates: Partial<ExtensionSettings>
): Promise<void> {
  const current = await getSettings();
  await chrome.storage.local.set({ settings: { ...current, ...updates } });
}

// Listen for storage changes in any context
chrome.storage.onChanged.addListener((changes, area) => {
  if (area === "local" && changes.settings) {
    console.log("Settings updated:", changes.settings.newValue);
    // Update UI if needed
  }
});

Building and Publishing

Build your extension for production and prepare it for Chrome Web Store submission:

# Build for production
npm run build
# Creates a dist/ folder with all extension files

# Package for Chrome Web Store (zip the dist folder)
cd dist && zip -r ../my-extension.zip . && cd ..

Chrome Web Store requirements: icon set (16, 32, 48, 128px PNG), at least 3 promotional screenshots at 1280x800 or 640x400, a clear description, and a privacy policy URL if you collect any user data. The review process typically takes 3-7 business days. Common rejection reasons: misleading description, overly broad host_permissions, missing privacy policy for extensions that access user data.

For production Chrome extensions with AI features, React UI, and Chrome Web Store submission, see my Chrome extension development service. Check the project portfolio for published extension examples, and visit my browser extensions service page for AI-powered extension builds.

Frequently Asked Questions

Can I use any React version with Chrome extensions?

Yes. React 18 and React 19 both work correctly in Chrome extension contexts. The popup, side panel, and options pages are standard HTML pages where React renders normally. Content scripts can also use React if you mount a shadow DOM or iframe to isolate your extension's styles from the host page's styles.

How do I use React state across the popup and content scripts?

React state does not persist across different extension contexts (popup, content script, service worker) because they run in separate processes. Use chrome.storage.local or chrome.storage.sync to persist state, and chrome.runtime.sendMessage for real-time communication between contexts. Think of each context as a separate React app that shares data through Chrome APIs.

Does Vite work well for Chrome extension development?

Yes, with the CRXJS Vite plugin or a custom Vite config with separate entry points. CRXJS is the easiest approach — it handles the Manifest V3 config, code splitting, and hot module replacement during development. The alternative is a manual Vite config with multiple entry points (popup.html, options.html) and a content scripts build that outputs plain JavaScript without ES modules (because content scripts cannot use ES module syntax).

Share:TwitterLinkedIn

Get the newsletter

Practical articles on AI development, full stack engineering, and WordPress — delivered whenI publish, not on a schedule. No spam, ever.

JO

Johnbert Oñez

AI Solutions Engineer & Full Stack Developer

Johnbert builds AI systems, web applications, and WordPress solutions for clients worldwide. Based in Davao City, Philippines. 6+ years, 50+ projects.

More Like This

Enjoyed the article? Let's build something together.

From AI chatbots to SaaS products — I turn ideas into working software. Based in Davao City, available worldwide.