How to Build a Chrome Extension with React and Vite
ID | EN

How to Build a Chrome Extension with React and Vite

Monday, Dec 29, 2025

Ever thought about building your own Chrome extension? Maybe there’s a feature you need but no extension exists for it, or you want to automate certain tasks in the browser.

The good news is, with React and Vite, Chrome Extension development becomes much more enjoyable. You can use all the React features you’re familiar with: components, hooks, state management—all of it works.

In this tutorial, we’ll build a simple productivity extension: Quick Notes. An extension that allows users to save quick notes and highlight text on any page.

What is a Chrome Extension?

A Chrome Extension is a small program that modifies or enhances the browsing experience in Chrome. Extensions can:

  • Add new features to the browser (ad blocker, password manager)
  • Modify website appearance (dark mode, font changer)
  • Integrate with other services (Grammarly, Notion)
  • Automate tasks (form filler, screenshot tool)

Popular extensions like uBlock Origin, React DevTools, and Grammarly—all are Chrome Extensions.

Chrome Extension Anatomy

Before coding, it’s important to understand the basic structure of a Chrome Extension:

my-extension/
├── manifest.json          # Main configuration file
├── popup/                 # UI that appears when clicking extension icon
│   ├── index.html
│   └── App.tsx
├── content-scripts/       # Scripts that run on web pages
│   └── content.ts
├── background/            # Service worker (background process)
│   └── background.ts
├── options/               # Extension settings page
│   └── Options.tsx
└── public/
    └── icons/             # Extension icons in various sizes

1. manifest.json

The most important file. Contains metadata and permissions required by the extension.

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "description": "Extension description",
  "permissions": ["storage", "activeTab"],
  "action": {
    "default_popup": "popup.html",
    "default_icon": "icon-48.png"
  }
}

2. Popup

The UI that appears when users click the extension icon in the toolbar. This is the main “frontend” of your extension—and this is where React shines.

3. Content Scripts

JavaScript injected into web pages. Content scripts can read and modify the DOM of the page currently open.

4. Background Script (Service Worker)

Scripts that run in the background, separate from web pages. Used for event handling, API calls, or operations that need to persist.

5. Options Page

Settings page for the extension. Users can access it via right-click extension icon → Options.

Manifest V3 vs V2

Since 2023, Chrome requires Manifest V3 for new extensions. Key differences:

AspectManifest V2Manifest V3
BackgroundPersistent background pagesService workers (event-driven)
APIchrome.browserActionchrome.action
Content SecurityMore looseMore strict
Remote CodeAllowedNot allowed
PermissionsGranted onceHost permissions can be revoked

We’ll use Manifest V3 in this tutorial.

Project Setup with Vite

There are several templates for Chrome Extensions with React, but we’ll use CRXJS—a Vite plugin that makes extension development seamless.

1. Create Project

npm create vite@latest quick-notes-extension -- --template react-ts
cd quick-notes-extension

2. Install CRXJS Plugin

npm install @crxjs/vite-plugin -D

3. Setup Vite Config

// filepath: /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 }),
  ],
})

4. Create manifest.json

{
  "manifest_version": 3,
  "name": "Quick Notes",
  "version": "1.0.0",
  "description": "Save quick notes and highlight text on any page",
  "permissions": [
    "storage",
    "activeTab",
    "scripting"
  ],
  "action": {
    "default_popup": "index.html",
    "default_icon": {
      "16": "icons/icon-16.png",
      "48": "icons/icon-48.png",
      "128": "icons/icon-128.png"
    }
  },
  "icons": {
    "16": "icons/icon-16.png",
    "48": "icons/icon-48.png",
    "128": "icons/icon-128.png"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["src/content/content.ts"],
      "css": ["src/content/content.css"]
    }
  ],
  "background": {
    "service_worker": "src/background/background.ts",
    "type": "module"
  }
}

5. Folder Structure

Reorganize project structure:

quick-notes-extension/
├── public/
│   └── icons/
│       ├── icon-16.png
│       ├── icon-48.png
│       └── icon-128.png
├── src/
│   ├── popup/
│   │   ├── App.tsx
│   │   ├── App.css
│   │   └── components/
│   ├── content/
│   │   ├── content.ts
│   │   └── content.css
│   ├── background/
│   │   └── background.ts
│   ├── utils/
│   │   └── storage.ts
│   └── types/
│       └── index.ts
├── index.html
├── manifest.json
├── vite.config.ts
└── package.json

6. Update index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Quick Notes</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/popup/main.tsx"></script>
  </body>
</html>

7. Create Entry Point

// filepath: /src/popup/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './App.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

Building Popup UI with React

Now let’s build the UI for the popup. We’ll create an interface to save and display notes.

Types Definition

// filepath: /src/types/index.ts
export interface Note {
  id: string;
  content: string;
  url: string;
  title: string;
  createdAt: number;
  color?: string;
}

export interface Highlight {
  id: string;
  text: string;
  url: string;
  createdAt: number;
}

Storage Utility

Chrome Extension has a built-in storage API. Let’s create a wrapper for easier use:

// filepath: /src/utils/storage.ts
import { Note, Highlight } from '../types';

const NOTES_KEY = 'quick_notes';
const HIGHLIGHTS_KEY = 'quick_highlights';

export const storage = {
  // Notes
  async getNotes(): Promise<Note[]> {
    const result = await chrome.storage.local.get(NOTES_KEY);
    return result[NOTES_KEY] || [];
  },

  async saveNote(note: Note): Promise<void> {
    const notes = await this.getNotes();
    notes.unshift(note);
    await chrome.storage.local.set({ [NOTES_KEY]: notes });
  },

  async deleteNote(id: string): Promise<void> {
    const notes = await this.getNotes();
    const filtered = notes.filter(n => n.id !== id);
    await chrome.storage.local.set({ [NOTES_KEY]: filtered });
  },

  // Highlights
  async getHighlights(): Promise<Highlight[]> {
    const result = await chrome.storage.local.get(HIGHLIGHTS_KEY);
    return result[HIGHLIGHTS_KEY] || [];
  },

  async saveHighlight(highlight: Highlight): Promise<void> {
    const highlights = await this.getHighlights();
    highlights.unshift(highlight);
    await chrome.storage.local.set({ [HIGHLIGHTS_KEY]: highlights });
  },

  async deleteHighlight(id: string): Promise<void> {
    const highlights = await this.getHighlights();
    const filtered = highlights.filter(h => h.id !== id);
    await chrome.storage.local.set({ [HIGHLIGHTS_KEY]: filtered });
  },

  // Clear all
  async clearAll(): Promise<void> {
    await chrome.storage.local.clear();
  }
};
// filepath: /src/popup/App.tsx
import { useState, useEffect } from 'react';
import { Note } from '../types';
import { storage } from '../utils/storage';
import NoteList from './components/NoteList';
import NoteForm from './components/NoteForm';
import './App.css';

function App() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [activeTab, setActiveTab] = useState<'notes' | 'highlights'>('notes');
  const [currentUrl, setCurrentUrl] = useState('');
  const [currentTitle, setCurrentTitle] = useState('');

  useEffect(() => {
    loadNotes();
    getCurrentTab();
  }, []);

  const loadNotes = async () => {
    const storedNotes = await storage.getNotes();
    setNotes(storedNotes);
  };

  const getCurrentTab = async () => {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    if (tab) {
      setCurrentUrl(tab.url || '');
      setCurrentTitle(tab.title || '');
    }
  };

  const handleAddNote = async (content: string) => {
    const newNote: Note = {
      id: crypto.randomUUID(),
      content,
      url: currentUrl,
      title: currentTitle,
      createdAt: Date.now(),
    };
    
    await storage.saveNote(newNote);
    setNotes(prev => [newNote, ...prev]);
  };

  const handleDeleteNote = async (id: string) => {
    await storage.deleteNote(id);
    setNotes(prev => prev.filter(n => n.id !== id));
  };

  return (
    <div className="popup-container">
      <header className="popup-header">
        <h1>📝 Quick Notes</h1>
        <p className="current-page" title={currentTitle}>
          {currentTitle.slice(0, 30) || 'New Tab'}
          {currentTitle.length > 30 && '...'}
        </p>
      </header>

      <nav className="tabs">
        <button 
          className={activeTab === 'notes' ? 'active' : ''} 
          onClick={() => setActiveTab('notes')}
        >
          Notes ({notes.length})
        </button>
        <button 
          className={activeTab === 'highlights' ? 'active' : ''} 
          onClick={() => setActiveTab('highlights')}
        >
          Highlights
        </button>
      </nav>

      <main className="popup-content">
        {activeTab === 'notes' && (
          <>
            <NoteForm onSubmit={handleAddNote} />
            <NoteList notes={notes} onDelete={handleDeleteNote} />
          </>
        )}
      </main>
    </div>
  );
}

export default App;

Note Form Component

// filepath: /src/popup/components/NoteForm.tsx
import { useState, FormEvent } from 'react';

interface NoteFormProps {
  onSubmit: (content: string) => void;
}

function NoteForm({ onSubmit }: NoteFormProps) {
  const [content, setContent] = useState('');

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    if (content.trim()) {
      onSubmit(content.trim());
      setContent('');
    }
  };

  return (
    <form onSubmit={handleSubmit} className="note-form">
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Write a note..."
        rows={3}
      />
      <button type="submit" disabled={!content.trim()}>
        + Add Note
      </button>
    </form>
  );
}

export default NoteForm;

Note List Component

// filepath: /src/popup/components/NoteList.tsx
import { Note } from '../../types';

interface NoteListProps {
  notes: Note[];
  onDelete: (id: string) => void;
}

function NoteList({ notes, onDelete }: NoteListProps) {
  const formatDate = (timestamp: number) => {
    return new Date(timestamp).toLocaleDateString('en-US', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit',
    });
  };

  if (notes.length === 0) {
    return (
      <div className="empty-state">
        <p>No notes yet</p>
        <small>Write something above 👆</small>
      </div>
    );
  }

  return (
    <ul className="note-list">
      {notes.map((note) => (
        <li key={note.id} className="note-item">
          <div className="note-content">
            <p>{note.content}</p>
            <div className="note-meta">
              <span className="note-page" title={note.title}>
                📄 {note.title.slice(0, 20)}...
              </span>
              <span className="note-date">{formatDate(note.createdAt)}</span>
            </div>
          </div>
          <button 
            className="delete-btn" 
            onClick={() => onDelete(note.id)}
            title="Delete"
          >
            ×
          </button>
        </li>
      ))}
    </ul>
  );
}

export default NoteList;

Content Scripts: Modifying Web Pages

Content scripts allow our extension to interact with the page the user is viewing. We’ll implement a text highlight feature.

Content Script

// filepath: /src/content/content.ts

// Listen for text selection
document.addEventListener('mouseup', handleTextSelection);

function handleTextSelection() {
  const selection = window.getSelection();
  const selectedText = selection?.toString().trim();

  if (selectedText && selectedText.length > 0) {
    showHighlightButton(selection!);
  } else {
    removeHighlightButton();
  }
}

function showHighlightButton(selection: Selection) {
  removeHighlightButton();

  const range = selection.getRangeAt(0);
  const rect = range.getBoundingClientRect();

  const button = document.createElement('button');
  button.id = 'quick-notes-highlight-btn';
  button.innerHTML = '✨ Highlight';
  button.style.cssText = `
    position: fixed;
    top: ${rect.top - 40 + window.scrollY}px;
    left: ${rect.left + rect.width / 2}px;
    transform: translateX(-50%);
    z-index: 999999;
    padding: 8px 12px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: white;
    border: none;
    border-radius: 6px;
    font-size: 12px;
    font-weight: 500;
    cursor: pointer;
    box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
  `;

  button.addEventListener('click', async () => {
    const text = selection.toString().trim();
    await highlightAndSave(text, selection);
    removeHighlightButton();
  });

  document.body.appendChild(button);
}

function removeHighlightButton() {
  const existingBtn = document.getElementById('quick-notes-highlight-btn');
  if (existingBtn) {
    existingBtn.remove();
  }
}

async function highlightAndSave(text: string, selection: Selection) {
  // Apply highlight style
  const range = selection.getRangeAt(0);
  const span = document.createElement('span');
  span.className = 'quick-notes-highlight';
  span.style.cssText = `
    background: linear-gradient(135deg, rgba(102, 126, 234, 0.3), rgba(118, 75, 162, 0.3));
    padding: 2px 4px;
    border-radius: 3px;
  `;
  
  range.surroundContents(span);

  // Save to storage via background script
  chrome.runtime.sendMessage({
    type: 'SAVE_HIGHLIGHT',
    payload: {
      id: crypto.randomUUID(),
      text,
      url: window.location.href,
      createdAt: Date.now(),
    }
  });

  selection.removeAllRanges();
}

// Remove button when clicking elsewhere
document.addEventListener('click', (e) => {
  const target = e.target as HTMLElement;
  if (target.id !== 'quick-notes-highlight-btn') {
    removeHighlightButton();
  }
});

Content Script CSS

/* filepath: /src/content/content.css */
.quick-notes-highlight {
  background: linear-gradient(135deg, rgba(102, 126, 234, 0.3), rgba(118, 75, 162, 0.3)) !important;
  padding: 2px 4px !important;
  border-radius: 3px !important;
  transition: background 0.2s !important;
}

.quick-notes-highlight:hover {
  background: linear-gradient(135deg, rgba(102, 126, 234, 0.5), rgba(118, 75, 162, 0.5)) !important;
}

Background Script (Service Worker)

The background script handles events and communication between extension parts.

// filepath: /src/background/background.ts

// Listen for messages from content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'SAVE_HIGHLIGHT') {
    saveHighlight(message.payload);
    sendResponse({ success: true });
  }
  return true;
});

async function saveHighlight(highlight: {
  id: string;
  text: string;
  url: string;
  createdAt: number;
}) {
  const result = await chrome.storage.local.get('quick_highlights');
  const highlights = result.quick_highlights || [];
  highlights.unshift(highlight);
  await chrome.storage.local.set({ quick_highlights: highlights });
}

// Optional: Set up badge to show note count
chrome.storage.onChanged.addListener((changes) => {
  if (changes.quick_notes) {
    const count = changes.quick_notes.newValue?.length || 0;
    chrome.action.setBadge({ text: count > 0 ? String(count) : '' });
    chrome.action.setBadgeBackgroundColor({ color: '#667eea' });
  }
});

// Handle installation
chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    console.log('Quick Notes installed!');
  }
});

Message Passing: Communication Between Components

In Chrome Extensions, there are several ways to communicate:

1. Content Script ↔ Background

// From content script
chrome.runtime.sendMessage({ type: 'ACTION', data: {} }, (response) => {
  console.log('Response:', response);
});

// In background script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'ACTION') {
    // Process...
    sendResponse({ success: true });
  }
  return true; // Keep channel open for async response
});

2. Popup ↔ Content Script

// From popup, send to content script in active tab
async function sendToContentScript(message: any) {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab.id) {
    return chrome.tabs.sendMessage(tab.id, message);
  }
}

// In content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'GET_SELECTION') {
    const selection = window.getSelection()?.toString();
    sendResponse({ text: selection });
  }
  return true;
});

3. Long-lived Connections

For more complex communication:

// Connect
const port = chrome.runtime.connect({ name: 'quick-notes' });

// Send message
port.postMessage({ type: 'PING' });

// Listen for messages
port.onMessage.addListener((msg) => {
  console.log('Received:', msg);
});

Chrome Storage API

Chrome Extension has two types of storage:

Local Storage

// Save
await chrome.storage.local.set({ key: 'value' });

// Get
const result = await chrome.storage.local.get('key');
console.log(result.key);

// Get multiple
const { key1, key2 } = await chrome.storage.local.get(['key1', 'key2']);

// Remove
await chrome.storage.local.remove('key');

// Clear all
await chrome.storage.local.clear();

Sync Storage

Syncs to user’s Google account (max 100KB total):

// Data will sync to all Chrome browsers logged into the same account
await chrome.storage.sync.set({ settings: { theme: 'dark' } });

const result = await chrome.storage.sync.get('settings');

Listen for Changes

chrome.storage.onChanged.addListener((changes, areaName) => {
  for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
    console.log(`${key} changed from`, oldValue, 'to', newValue);
  }
});

Development & Testing

Run Development Server

npm run dev

CRXJS will generate a dist/ folder with the extension ready to load.

Load Extension into Chrome

  1. Open chrome://extensions/
  2. Enable “Developer mode” (toggle in top right)
  3. Click “Load unpacked”
  4. Select the dist/ folder from your project

Hot Reload

CRXJS supports HMR! Changes in popup will update immediately. For content scripts, you need to reload the extension.

Debugging

  • Popup: Right-click extension icon → Inspect popup
  • Background: At chrome://extensions/, click “Service Worker” link
  • Content Scripts: DevTools on the web page → Sources → Content Scripts

Build for Production

npm run build

The dist/ folder is ready to publish.

Publish to Chrome Web Store

1. Preparation

Before publishing, prepare:

  • Extension screenshots (1280x800 or 640x400)
  • Promotional images (optional but recommended)
  • Complete description
  • Privacy policy (if collecting user data)

2. Register Developer Account

  1. Open Chrome Web Store Developer Dashboard
  2. Pay one-time $5 fee
  3. Verify email

3. Package Extension

Zip the dist/ folder:

cd dist
zip -r ../quick-notes-extension.zip .

4. Upload & Submit

  1. In Developer Dashboard, click “New Item”
  2. Upload ZIP file
  3. Fill in all requested information
  4. Submit for review

Review usually takes 1-3 business days. If there are issues, Chrome will send feedback.

Tips & Best Practices

Performance

// ❌ Bad: Check storage on every event
document.addEventListener('scroll', async () => {
  const data = await chrome.storage.local.get('settings'); // Expensive!
});

// ✅ Good: Cache data
let cachedSettings: Settings | null = null;

async function getSettings() {
  if (!cachedSettings) {
    const result = await chrome.storage.local.get('settings');
    cachedSettings = result.settings;
  }
  return cachedSettings;
}

Permissions

Request only the permissions you actually need:

{
  "permissions": ["storage"], // Required
  "optional_permissions": ["tabs", "history"], // Request when needed
  "host_permissions": ["https://specific-site.com/*"] // Minimal scope
}

Error Handling

try {
  await chrome.storage.local.set({ data: value });
} catch (error) {
  if (error.message.includes('QUOTA_BYTES')) {
    console.error('Storage quota exceeded');
    // Handle cleanup
  }
}

Content Script Isolation

Content scripts run in an isolated world but share the DOM:

// Access page's window object
const pageWindow = window.wrappedJSObject; // Firefox
// In Chrome, need to inject script to page context

function injectScript(code: string) {
  const script = document.createElement('script');
  script.textContent = code;
  document.head.appendChild(script);
  script.remove();
}

Conclusion

Building Chrome Extensions with React and Vite provides a modern developer experience:

  • Component-based UI with React
  • Hot Module Replacement for fast development
  • TypeScript support out of the box
  • Modern tooling with Vite

The extension we built covers important features:

  • ✅ Popup UI with React
  • ✅ Content scripts to modify pages
  • ✅ Background service worker
  • ✅ Chrome Storage API
  • ✅ Message passing between components

From here, you can expand the extension with additional features: cross-device sync, keyboard shortcuts, context menus, and much more.

Resources

Happy building! 🚀