🌐 Detecting your location…

How to Build a Chrome Extension with Manifest V3 in 2026: Complete Guide

⏱️6 min read  ·  1,316 words

Manifest V3 is now the only option for new Chrome extensions, and it changes the architecture meaningfully: background pages became ephemeral service workers, remote code execution is banned, and network interception moved to a declarative API. If you learned extension development on V2, several habits no longer work. This guide builds a complete, working extension from scratch under V3 rules.

What We Are Building

A page-annotation extension: it adds a toolbar popup, injects a content script that highlights selected text on any page, saves highlights per URL, and syncs them through chrome.storage. It exercises every part of V3 you will actually use — popup UI, content script, service worker, messaging, storage, and permissions.

Step 1: Project Structure and the Manifest

Create a directory with these files. The manifest is the entry point and Chrome reads it first.

my-extension/
  manifest.json
  background.js
  content.js
  popup.html
  popup.js
  styles.css
  icons/icon16.png icon48.png icon128.png

The manifest declares version 3, your entry points, and permissions. Keep permissions minimal — every extra permission slows review and scares users at install time.

{
  "manifest_version": 3,
  "name": "Page Highlighter",
  "version": "1.0.0",
  "description": "Highlight and save text on any page.",
  "permissions": ["storage", "activeTab", "scripting"],
  "host_permissions": ["http://*/*", "https://*/*"],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "content_scripts": [
    {
      "matches": ["http://*/*", "https://*/*"],
      "js": ["content.js"],
      "css": ["styles.css"],
      "run_at": "document_idle"
    }
  ],
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

Prefer activeTab over broad host permissions when you can. It grants access to the current tab only after the user clicks your extension icon, which is far easier to justify in review.

Step 2: The Service Worker Is Not a Background Page

This is the single biggest V3 change. Your background script is a service worker that Chrome terminates when idle and restarts on the next event. Any variable you set at the top level is gone after termination. State must live in chrome.storage, not in memory.

// background.js

// WRONG under V3 — this resets every time the worker restarts.
// let highlightCount = 0;

chrome.runtime.onInstalled.addListener(async () => {
  const { highlights } = await chrome.storage.local.get('highlights');
  if (!highlights) {
    await chrome.storage.local.set({ highlights: {} });
  }
});

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'SAVE_HIGHLIGHT') {
    saveHighlight(message.payload).then(() => sendResponse({ ok: true }));
    // Returning true keeps the message channel open for the async reply.
    return true;
  }
});

async function saveHighlight({ url, text }) {
  const { highlights = {} } = await chrome.storage.local.get('highlights');
  const forPage = highlights[url] || [];
  forPage.push({ text, createdAt: Date.now() });
  highlights[url] = forPage;
  await chrome.storage.local.set({ highlights });
}

The return true in the message listener is easy to miss and causes a classic bug: your async handler completes but the sender never receives the response, because Chrome closed the channel when the listener returned undefined.

Step 3: The Content Script

Content scripts run in the page’s DOM but in an isolated JavaScript world. They can read and modify the DOM, but cannot see the page’s own JavaScript variables. That isolation is a security feature, not a limitation to work around.

// content.js
document.addEventListener('mouseup', async () => {
  const selection = window.getSelection();
  const text = selection.toString().trim();
  if (text.length < 3) return;

  const range = selection.getRangeAt(0);
  const mark = document.createElement('mark');
  mark.className = 'ext-highlight';

  try {
    range.surroundContents(mark);
  } catch {
    // surroundContents throws when the selection crosses element boundaries.
    return;
  }

  await chrome.runtime.sendMessage({
    type: 'SAVE_HIGHLIGHT',
    payload: { url: location.href, text }
  });

  selection.removeAllRanges();
});

Note the try/catch around surroundContents. It throws whenever the selection spans multiple elements, which happens constantly in real pages. Handling it is the difference between an extension that works on your test page and one that works everywhere.

Step 4: The Popup

The popup is an ordinary web page with access to extension APIs. It gets destroyed every time it closes, so treat it as stateless and read from storage on open.

<!-- popup.html -->
<!DOCTYPE html>
<html>
  <head><meta charset="utf-8"></head>
  <body style="width:320px;font:14px system-ui;padding:12px">
    <h1 style="font-size:15px;margin:0 0 8px">Highlights on this page</h1>
    <ul id="list"></ul>
    <script src="popup.js"></script>
  </body>
</html>

Inline scripts are blocked by the extension content security policy, so the <script src> reference is mandatory — you cannot put JavaScript directly in the HTML.

// popup.js
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const { highlights = {} } = await chrome.storage.local.get('highlights');
const items = highlights[tab.url] || [];

const list = document.getElementById('list');
if (items.length === 0) {
  list.innerHTML = '<li>No highlights yet.</li>';
} else {
  for (const item of items) {
    const li = document.createElement('li');
    li.textContent = item.text;   // textContent, never innerHTML
    list.appendChild(li);
  }
}

Use textContent rather than innerHTML for any value that originated from a web page. Highlighted text is attacker-controlled data, and building HTML from it is a straightforward XSS route into your extension's privileged context.

Step 5: Storage Choices

Chrome gives you three storage areas and picking wrong causes silent failures.

Area Quota Use for
storage.local ~10MB (unlimited with permission) Bulk data, cached content
storage.sync ~100KB total, 8KB per item Small user settings, synced across devices
storage.session ~10MB, memory only Data that should not survive a browser restart

The common mistake is putting user content in storage.sync because syncing sounds desirable. The 8KB per-item limit is reached quickly and writes then fail — often silently, if you do not check for errors.

Step 6: Network Interception Changed

The blocking webRequest API is gone. If you need to block or redirect requests, use declarativeNetRequest, where you register static rules that Chrome evaluates itself. Your extension never sees the request.

{
  "permissions": ["declarativeNetRequest"],
  "declarative_net_request": {
    "rule_resources": [{
      "id": "ruleset_1",
      "enabled": true,
      "path": "rules.json"
    }]
  }
}

This is more restrictive by design, and it is why several ad blockers had to be rewritten. If your extension's core value depends on inspecting request bodies at runtime, V3 may genuinely not support it.

Step 7: Load and Debug

Open chrome://extensions, enable Developer mode, and choose "Load unpacked". Three separate consoles exist and knowing which to open saves hours:

  • Service worker: click the "service worker" link on your extension card
  • Popup: right-click the popup and choose Inspect
  • Content script: the normal page DevTools console, with the context selector set to your extension

If the service worker seems dead, that is expected — it terminates after roughly 30 seconds of inactivity. Trigger an event and it restarts.

Step 8: Publishing

Zip the extension directory contents (not the enclosing folder) and upload through the Chrome Web Store Developer Dashboard, which requires a one-time registration fee. Review time depends heavily on your permissions: an extension using only activeTab and storage typically clears review quickly, while broad host permissions plus scripting attract manual review and can take considerably longer. Write a clear justification for every permission in the listing — reviewers reject vague explanations.

Common Mistakes

Storing state in service worker globals. It disappears on termination. Use chrome.storage.

Forgetting return true in async message listeners. The response never arrives and the failure is silent.

Requesting <all_urls> when activeTab would do. It slows review and reduces installs.

Loading remote code. V3 forbids it outright. All executable code must ship in the package, which means no CDN scripts and no eval.

Conclusion

Manifest V3 development comes down to a few disciplines: treat the service worker as stateless and keep all state in chrome.storage, request the narrowest permissions that work, use textContent for anything sourced from a page, remember return true for async message handlers, and ship every line of code inside the package. Build with those constraints from the start and the platform stays out of your way — retrofitting them onto a V2-shaped design is where the pain lives.

MD Rafikul Islam

Written by

MD Rafikul Islam is a software developer and the editor of TechPulse. He writes about developer tooling, hardware, and the practical decisions that come up in day-to-day engineering work — which laptop to buy, which framework to commit to, why a build broke at 2am. He tests the tools he writes about and says plainly when something is not worth the money. Corrections and corrections requests are welcome at rony.yf25@gmail.com.

✍️ Leave a Comment

Your email address will not be published. Required fields are marked *

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা