Manifest V2 to V3 migration guide covering background page to service worker, API changes, webRequest to declarativeNetRequest, remote code removal, executeScript changes, and persistence patterns. Use when upgrading extensions to Manifest V3.
How this skill is triggered — by the user, by Claude, or both
Slash command
/chrome-extension-expert:skills/migration-guideThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
| Feature | Manifest V2 | Manifest V3 |
| Feature | Manifest V2 | Manifest V3 |
|---|---|---|
| Background | Persistent page | Service worker |
| Remote code | Allowed | Forbidden |
| Web requests | webRequest API | declarativeNetRequest |
| Host permissions | In permissions | Separate field |
| Content scripts | executeScript string | executeScript files/func |
| CSP | Customizable | Restricted |
| Action | browser_action/page_action | action |
MV2:
{
"manifest_version": 2,
"name": "My Extension",
"version": "1.0",
"browser_action": {
"default_popup": "popup.html",
"default_icon": "icon.png"
},
"background": {
"scripts": ["background.js"],
"persistent": false
},
"permissions": [
"tabs",
"storage",
"*://*.example.com/*"
]
}
MV3:
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0",
"action": {
"default_popup": "popup.html",
"default_icon": "icon.png"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": [
"tabs",
"storage"
],
"host_permissions": [
"*://*.example.com/*"
]
}
browser_action / page_action → actionbackground.scripts → background.service_workerhost_permissions"type": "module" for ES modules| Background Page | Service Worker |
|---|---|
| Persistent (optional) | Always terminates |
| DOM access | No DOM |
| window object | No window |
| localStorage | No localStorage |
| XMLHttpRequest | fetch only |
| setTimeout reliable | setTimeout may not fire |
1. Remove DOM dependencies:
// MV2 - Using DOM
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 content'
});
// Send HTML to offscreen document for parsing
2. Replace window with self:
// MV2
window.addEventListener('message', handler);
// MV3
self.addEventListener('message', handler);
3. Handle termination:
// MV2 - Persistent state
let cachedData = null;
// MV3 - Must persist to storage
async function getCachedData() {
const { cachedData } = await chrome.storage.session.get('cachedData');
return cachedData;
}
async function setCachedData(data) {
await chrome.storage.session.set({ cachedData: data });
}
4. Register listeners at top level:
// MV2 - Could add listeners anytime
setTimeout(() => {
chrome.runtime.onMessage.addListener(handler);
}, 1000);
// MV3 - Must be at top level
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Can still handle asynchronously
handleMessage(message).then(sendResponse);
return true;
});
// MV2 - setInterval
setInterval(checkForUpdates, 60000);
// MV3 - Alarms
chrome.alarms.create('checkUpdates', { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'checkUpdates') {
checkForUpdates();
}
});
// Service worker can terminate anytime
// Must save state to storage
// On state change
async function updateState(newState) {
state = { ...state, ...newState };
await chrome.storage.session.set({ state });
}
// On service worker start
async function restoreState() {
const { state } = await chrome.storage.session.get('state');
return state || initialState;
}
// Restore state immediately on load
let state;
restoreState().then(s => state = s);
// For long-running operations
// Create an alarm to keep service worker alive
async function startLongOperation() {
chrome.alarms.create('keepAlive', { periodInMinutes: 0.5 });
try {
await longOperation();
} finally {
chrome.alarms.clear('keepAlive');
}
}
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'keepAlive') {
// Just keeps worker alive
}
});
MV2 - webRequest (blocking):
chrome.webRequest.onBeforeRequest.addListener(
(details) => {
if (shouldBlock(details.url)) {
return { cancel: true };
}
},
{ urls: ['<all_urls>'] },
['blocking']
);
MV3 - declarativeNetRequest:
manifest.json:
{
"permissions": ["declarativeNetRequest"],
"declarative_net_request": {
"rule_resources": [{
"id": "ruleset_1",
"enabled": true,
"path": "rules.json"
}]
}
}
rules.json:
[
{
"id": 1,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "||ads.example.com",
"resourceTypes": ["script", "image", "xmlhttprequest"]
}
},
{
"id": 2,
"priority": 1,
"action": {
"type": "redirect",
"redirect": { "extensionPath": "/blocked.html" }
},
"condition": {
"urlFilter": "||tracking.com/*",
"resourceTypes": ["main_frame"]
}
}
]
// Add rules at runtime
await chrome.declarativeNetRequest.updateDynamicRules({
addRules: [{
id: 100,
priority: 1,
action: { type: 'block' },
condition: {
urlFilter: userBlockedDomain,
resourceTypes: ['main_frame', 'sub_frame']
}
}],
removeRuleIds: [99] // Remove old rule
});
// Session rules (cleared on browser restart)
await chrome.declarativeNetRequest.updateSessionRules({
addRules: [/* ... */]
});
Some use cases still require webRequest (without blocking):
{
"permissions": ["webRequest"],
"host_permissions": ["*://*.example.com/*"]
}
// Non-blocking observation still works
chrome.webRequest.onCompleted.addListener(
(details) => {
logRequest(details);
},
{ urls: ['*://*.example.com/*'] }
);
Not Allowed in MV3:
// Loading external scripts
const script = document.createElement('script');
script.src = 'https://external.com/script.js'; // Blocked
// Eval and Function constructor
eval(code); // Blocked
new Function(code); // Blocked
// External script tags in HTML
<script src="https://cdn.example.com/lib.js"></script> // Blocked
1. Bundle all dependencies:
npm install library
# Bundle with webpack/rollup/esbuild
2. For dynamic configuration:
// MV2 - Fetch and eval config
const config = await fetch('https://api.com/config.js');
eval(config);
// MV3 - Fetch JSON data only
const response = await fetch('https://api.com/config.json');
const config = await response.json();
// Use config data, don't execute code
3. For user scripts (sandbox):
{
"sandbox": {
"pages": ["sandbox.html"]
}
}
// sandbox.html can use eval
// Communicate via postMessage
const frame = document.getElementById('sandbox');
frame.contentWindow.postMessage({ code: userCode }, '*');
// Execute string of code
chrome.tabs.executeScript(tabId, {
code: 'document.body.style.background = "red"'
});
// Execute file
chrome.tabs.executeScript(tabId, {
file: 'content.js'
});
// Execute function
await chrome.scripting.executeScript({
target: { tabId },
func: () => {
document.body.style.background = 'red';
}
});
// Execute function with arguments
await chrome.scripting.executeScript({
target: { tabId },
func: (color) => {
document.body.style.background = color;
},
args: ['red']
});
// Execute file
await chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
});
// Specify world
await chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN', // Access page's JavaScript context
func: () => window.somePageVariable
});
// MV2
chrome.tabs.insertCSS(tabId, { code: 'body { color: red; }' });
// MV3
await chrome.scripting.insertCSS({
target: { tabId },
css: 'body { color: red; }'
});
// Or file
await chrome.scripting.insertCSS({
target: { tabId },
files: ['styles.css']
});
// MV2
chrome.browserAction.setIcon({ path: 'icon.png' });
chrome.browserAction.setBadgeText({ text: '5' });
chrome.browserAction.onClicked.addListener(handler);
chrome.pageAction.show(tabId);
// MV3
chrome.action.setIcon({ path: 'icon.png' });
chrome.action.setBadgeText({ text: '5' });
chrome.action.onClicked.addListener(handler);
// No separate pageAction - use action for all
chrome.action.enable(tabId);
chrome.action.disable(tabId);
{
"web_accessible_resources": [
"images/*",
"script.js"
]
}
{
"web_accessible_resources": [{
"resources": ["images/*", "script.js"],
"matches": ["*://*.example.com/*"]
}, {
"resources": ["public/*"],
"matches": ["<all_urls>"],
"use_dynamic_url": true
}]
}
{
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
}
{
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
}
}
Restrictions:
unsafe-evalunsafe-inlinewasm-unsafe-eval allowed for WebAssemblymanifest_version to 3browser_action/page_action with actionhost_permissionsbackground to service_workerweb_accessible_resources formatwindow with selfexecuteScript callsinsertCSS callschrome.scripting APIwebRequest blocking with declarativeNetRequestProblem: State lost when worker terminates
Solution:
// Use storage instead of variables
chrome.storage.session.set({ key: value });
// Restore on start
chrome.storage.session.get('key').then(({ key }) => {
// Use restored value
});
Problem: No DOMParser in service worker
Solution: Use offscreen document
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['DOM_PARSER'],
justification: 'Parse HTML'
});
Problem: webRequest blocking not available
Solution: Use declarativeNetRequest with static rules
Problem: Can't execute user-provided code
Solution: Sandbox page with postMessage communication
Problem: Can't load from CDN
Solution: Bundle with npm/webpack
npx claudepluginhub francanete/fran-marketplace --plugin chrome-extension-expertBuilds and migrates Chrome Extensions using Manifest V3, covering service workers, content scripts, message passing, and extension APIs.
Guides Chrome extension development with Manifest V3: manifest.json setup, service workers, content scripts, messaging RPC, UI surfaces (popup/side panel), storage, permissions, CSP bypass, TypeScript builds, and publishing.
Builds Manifest V3 browser extensions emphasizing service worker persistence (alarms, offscreen API, storage), side panels, security, and cross-browser support.