Chrome Extension Manifest V3 Migration Guide — Manifest V3 Guide

5 min read

Manifest V3 Migration Guide

Quick Checklist

  1. Change "manifest_version": 23
  2. Replace "background": { "scripts": [...] }"background": { "service_worker": "background.js" }
  3. Replace "browser_action" / "page_action""action"
  4. Move host permissions from "permissions""host_permissions"
  5. Replace chrome.browserAction / chrome.pageActionchrome.action
  6. Replace chrome.webRequest blocking → chrome.declarativeNetRequest
  7. Update CSP: no unsafe-eval, no remote scripts
  8. Replace chrome.tabs.executeScriptchrome.scripting.executeScript
  9. Replace chrome.tabs.insertCSSchrome.scripting.insertCSS
  10. Handle service worker lifecycle (no persistent state)

Manifest Changes

// MV2
{
  "manifest_version": 2,
  "background": { "scripts": ["bg.js"], "persistent": false },
  "browser_action": { "default_popup": "popup.html" },
  "permissions": ["tabs", "https://api.example.com/*"]
}

// MV3
{
  "manifest_version": 3,
  "background": { "service_worker": "background.js" },
  "action": { "default_popup": "popup.html" },
  "permissions": ["tabs"],
  "host_permissions": ["https://api.example.com/*"]
}

Background Script → Service Worker

No DOM Access

// MV2 (had DOM in background page)
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');

// MV3 — use offscreen document
await chrome.offscreen.createDocument({
  url: 'offscreen.html',
  reasons: ['DOM_PARSER'],
  justification: 'Parse HTML'
});

No Persistent State

// MV2
let count = 0;
chrome.browserAction.onClicked.addListener(() => count++);

// MV3 — use storage
import { createStorage, defineSchema } from '@theluckystrike/webext-storage';
const storage = createStorage(defineSchema({ count: 'number' }), 'local');
chrome.action.onClicked.addListener(async () => {
  const c = (await storage.get('count')) || 0;
  await storage.set('count', c + 1);
});

No setInterval/setTimeout (long-running)

// MV2
setInterval(() => poll(), 60000);

// MV3 — use alarms
chrome.alarms.create('poll', { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener(a => { if (a.name === 'poll') poll(); });

Web Request → Declarative Net Request

// MV2 (blocking webRequest)
chrome.webRequest.onBeforeRequest.addListener(
  () => ({ cancel: true }),
  { urls: ['*://ads.example.com/*'] },
  ['blocking']
);

// MV3 (declarativeNetRequest rules)
// rules.json:
[{
  "id": 1,
  "priority": 1,
  "action": { "type": "block" },
  "condition": { "urlFilter": "||ads.example.com", "resourceTypes": ["script", "image"] }
}]

Scripting API Changes

// MV2
chrome.tabs.executeScript(tabId, { code: 'alert("hi")' });
chrome.tabs.executeScript(tabId, { file: 'content.js' });

// MV3
await chrome.scripting.executeScript({
  target: { tabId },
  func: () => alert('hi')
});
await chrome.scripting.executeScript({
  target: { tabId },
  files: ['content.js']
});

Action API

// MV2
chrome.browserAction.setIcon({ path: 'icon.png' });
chrome.browserAction.onClicked.addListener(handler);

// MV3
chrome.action.setIcon({ path: 'icon.png' });
chrome.action.onClicked.addListener(handler);

Content Security Policy

// MV2  allowed remote scripts
{ "content_security_policy": "script-src 'self' https://cdn.example.com; object-src 'self'" }

// MV3  no remote scripts, no eval
{ "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" } }

Messaging with @theluckystrike/webext-messaging

// Works the same in MV2 and MV3
import { createMessenger } from '@theluckystrike/webext-messaging';
type Msgs = { ACTION: { request: { data: string }; response: { ok: boolean } } };
const m = createMessenger<Msgs>();
// Handles SW lifecycle automatically

Common Migration Pitfalls

Cross-References

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.