How to Build a Chrome Extension with Manifest V3

Published:

Umair Akhter

6 MIN READ

Chrome extensions are one of the most underrated things a developer can build. A good extension sits inside the browser, touches every page a user visits, and can save real hours of work per week. I’ve shipped three of them — SalesBar for eBay, Amazon Max Quantity Checker, and eBay Variation Counter — all in Manifest V3. Here’s the practical guide I wish existed when I started.

What changed with Manifest V3

Manifest V3 (MV3) is Google’s current extension platform. It replaced Manifest V2 in 2023. The core differences that matter in practice:

  • Service workers instead of background pages. Background scripts no longer run persistently. They wake up when needed and go to sleep. This means you cannot store state in memory — you must use chrome.storage or pass messages.
  • No more webRequestBlocking. If your extension intercepts and modifies network requests, you now use the Declarative Net Request API instead.
  • Content Security Policy is stricter. You can’t execute arbitrary remote code. All scripts must be bundled with the extension.

For most extensions — especially ones that read page data and show overlays — the migration is straightforward. The service worker change is the main thing to internalize.

Project structure

A minimal MV3 extension has four files:

my-extension/
├── manifest.json       ← declares everything
├── content.js          ← runs on the page
├── background.js       ← service worker
└── popup.html          ← optional toolbar popup

Here’s a working manifest.json template:

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "description": "What it does in one sentence.",
  "permissions": ["storage", "activeTab"],
  "host_permissions": ["https://example.com/*"],
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html"
  }
}

Only declare the permissions you actually need. Reviewers will reject extensions that request permissions without clear justification, and users are more likely to install extensions with minimal permission requirements.

Content scripts: reading and modifying the page

Content scripts run in the context of the page. They can read the DOM, modify it, and send messages to the background service worker. They cannot use most Chrome APIs directly.

A basic content script that reads data and injects a UI element:

// content.js
(function () {
  "use strict";

  // Read data from the page
  const priceEl = document.querySelector(".price");
  if (!priceEl) return;

  const price = parseFloat(priceEl.textContent.replace(/[^0-9.]/g, ""));

  // Inject your UI
  const badge = document.createElement("div");
  badge.textContent = `Margin: ${calculateMargin(price)}%`;
  badge.style.cssText = "position:fixed;top:10px;right:10px;background:#000;color:#fff;padding:8px 12px;border-radius:4px;z-index:9999;font-size:14px;";

  // Safe DOM insertion — never use innerHTML with user data
  document.body.appendChild(badge);

  function calculateMargin(cost) {
    return ((cost * 0.3) / cost * 100).toFixed(1);
  }
})();

Key rules for content scripts:

  • Wrap everything in an IIFE or use ES modules to avoid polluting the page’s global scope
  • Never use innerHTML with data that came from the page — XSS risk
  • Use MutationObserver when the page loads content dynamically (React/Vue SPAs, infinite scroll)

Handling dynamic pages with MutationObserver

Most modern sites are SPAs or load content after the initial HTML. A content script that runs once at document_idle will miss content that loads later. The fix:

// Watch for new content to appear
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeType !== Node.ELEMENT_NODE) continue;
      if (node.matches(".product-card") || node.querySelector(".product-card")) {
        processProductCards();
      }
    }
  }
});

observer.observe(document.body, { childList: true, subtree: true });

This is the pattern I use in the eBay and Amazon extensions. eBay’s listing pages load purchase history data asynchronously — without MutationObserver, the extension would inject before the data exists.

The service worker: background logic

The service worker handles work that shouldn’t run on-page: API calls, caching, cross-tab coordination. Because it’s not persistent, every handler must be idempotent and stateless.

// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "FETCH_DATA") {
    fetchAndCache(message.url).then(sendResponse);
    return true; // required to use sendResponse asynchronously
  }
});

async function fetchAndCache(url) {
  const cached = await chrome.storage.local.get(url);
  if (cached[url]) return cached[url];

  const res = await fetch(url);
  const data = await res.json();

  await chrome.storage.local.set({ [url]: data });
  return data;
}

The return true in the message listener is critical — without it, the sendResponse callback will be called with undefined before your async work completes.

Submitting to the Chrome Web Store

  1. Create a developer account at chrome.google.com/webstore/devconsole. One-time $5 registration fee.
  2. Zip your extension files (not the parent folder — zip the contents).
  3. Upload the zip and fill in the store listing: description, screenshots (at least one 1280×800), category, and privacy policy URL.
  4. Justify every permission in the “Single purpose” field. Be specific. “activeTab is used to read product data on the current eBay listing page” passes. “To provide functionality” does not.
  5. Expect a 2–5 business day review for new submissions. Updates are usually faster.

The review process is stricter than it used to be. Extensions that request broad permissions (tabs, webRequest, <all_urls>) face longer reviews and higher rejection rates. If you can accomplish the same thing with activeTab and specific host_permissions, do that.

Common mistakes

Storing large data in chrome.storage.sync — sync storage is limited to 100KB total and 8KB per key. Use chrome.storage.local for anything substantial.

Using chrome.tabs.query without the tabs permission — you need the tabs permission to read tab URLs. If you only need the currently active tab, use activeTab instead (much less friction with reviewers).

Forgetting that service workers restart — if you set a variable in a service worker, it’s gone the next time it wakes up. Everything that needs to persist must go into chrome.storage or be refetched.

Not testing on multiple page states — test when the page is: freshly loaded, after navigation within a SPA, after partial DOM updates, and in logged-out state.

What I’d build first

The best first extension is one that solves a problem you personally have, on a site you already use daily. You’ll test it naturally, you’ll notice the bugs, and you’ll care enough to fix them.

Start with a content script that reads a number from the page and displays it differently. Get that working. Then add caching. Then add the popup. Build up incrementally rather than designing the full architecture upfront.

The extensions I’ve shipped all started as a single content script with a console.log. The architecture came after the problem was validated.