Minimal Skeleton required
Smallest valid Manifest V3 manifest.json.
json
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A short description of what the extension does."
}
Full starter template
json
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A Chrome extension built with Manifest V3.",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png"
},
"default_title": "My Extension"
},
"background": {
"service_worker": "background.js"
},
"permissions": ["storage", "activeTab"]
}
Top-Level Fields
| Field | Type | Required | Description |
|---|---|---|---|
manifest_version | integer | Yes | Must be 3 for Manifest V3 |
name | string | Yes | Extension name (max 45 chars) |
version | string | Yes | 1β4 dot-separated integers (0β65535), e.g. "1.0.0" |
description | string | No | Description (max 132 chars for CWS) |
icons | object | No | Map of size β path. Required: 128Γ128 for CWS |
action | object | No | Configures the toolbar icon and popup |
background | object | No | Service worker configuration |
content_scripts | array | No | Scripts injected into pages |
permissions | array | No | Granted at install |
optional_permissions | array | No | Requested at runtime |
host_permissions | array | No | Host match patterns for cross-origin access |
optional_host_permissions | array | No | Host permissions requested at runtime |
options_page | string | No | Full options page path |
options_ui | object | No | Embedded options page config |
commands | object | No | Keyboard shortcuts |
omnibox | object | No | Address bar keyword |
web_accessible_resources | array | No | Resources accessible from pages |
content_security_policy | object | No | CSP for extension pages |
minimum_chrome_version | string | No | Minimum Chrome version required |
short_name | string | No | Fallback for limited space (max 12 chars) |
homepage_url | string | No | Extension homepage |
author | string | No | Developer email |
offline_enabled | boolean | No | Works offline (default false) |
incognito | string | No | "spanning" | "split" | "not_allowed" |
default_locale | string | No | e.g. "en" β required if _locales exists |
update_url | string | No | Manual update URL (non-CWS) |
Version format
1β4 segments, each 0β65535. Leading zeros not allowed.
"version": "1.0.0" // valid "version": "2.10.3.1" // valid (4 segments) "version": "1.01" // INVALID (leading zero)
Icons
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
Always provide 128Γ128 β required by Chrome Web Store. Use PNG format.
Permissions
Permissions Table
| Permission | API / Use | Warn |
|---|---|---|
activeTab | Temporary access to current tab when invoked | No |
storage | chrome.storage.local / sync / session | No |
tabs | chrome.tabs β query, create, update tabs | Yes |
scripting | chrome.scripting.executeScript / insertCSS | Yes |
alarms | chrome.alarms β periodic background tasks | No |
notifications | chrome.notifications β system notifications | Yes |
contextMenus | chrome.contextMenus β right-click menus | No |
bookmarks | chrome.bookmarks | Yes |
history | chrome.history | Yes |
cookies | chrome.cookies | Yes |
webRequest | Observe network requests (not block in MV3) | Yes |
declarativeNetRequest | Block/modify requests via rules (MV3 replacement for webRequestBlocking) | Yes |
sidePanel | chrome.sidePanel β side panel UI | No |
offscreen | chrome.offscreen β offscreen documents for DOM/audio | No |
identity | chrome.identity β OAuth2 flow | Yes |
idle | chrome.idle β detect user idle state | No |
power | chrome.power β keep awake | No |
printerProvider | chrome.printerProvider | No |
favicon | chrome favicon API | No |
topSites | chrome.topSites | Yes |
management | chrome.management β manage other extensions | Yes |
downloads | chrome.downloads | Yes |
system.cpu | chrome.system.cpu | No |
system.memory | chrome.system.memory | No |
system.storage | chrome.system.storage | No |
tts | chrome.tts β text-to-speech | No |
clipboardRead | Read from clipboard | Yes |
clipboardWrite | Write to clipboard | No |
geolocation | Access geolocation (requires host permission) | Yes |
unlimitedStorage | Remove 5MB storage quota | No |
Permissions snippet
json
{
"permissions": [
"storage",
"activeTab",
"scripting",
"alarms",
"contextMenus"
],
"optional_permissions": [
"bookmarks",
"notifications"
],
"host_permissions": [
"https://api.example.com/*"
],
"optional_host_permissions": [
"https://*.example.com/*"
]
}
activeTab vs host_permissions
Use
activeTab when you only need access to the current tab in response to a user click. It avoids the permission warning. Use host_permissions only when you need persistent access to specific sites.Runtime permission request
js
// Request optional permission at runtime
chrome.permissions.request({
permissions: ["bookmarks"],
origins: ["https://api.example.com/*"]
}, (granted) => {
if (granted) {
console.log("Permission granted");
}
});
// Check if permission is already granted
chrome.permissions.contains({
permissions: ["bookmarks"]
}, (has) => {
console.log("Has bookmarks:", has);
});
// Remove optional permission
chrome.permissions.remove({
permissions: ["bookmarks"]
}, (removed) => {
console.log("Removed:", removed);
});
Service Worker (Background)
Manifest V3 replaces persistent background pages with service workers β event-driven, no DOM access, can be terminated by Chrome.
Configuration
json
"background": {
"service_worker": "background.js",
"type": "module"
}
Add
"type": "module" to use ES module imports in your service worker.Service worker lifecycle
- Starts on events (install, message, alarm, action click)
- Chrome may terminate it after ~30 seconds of inactivity
- No DOM β use
chrome.offscreenif you need DOM/WebSocket - Persist data in
chrome.storage, not in-memory variables - Use
chrome.alarmsinstead ofsetInterval
Essential service worker patterns
js
// background.js β Service Worker
// Install event
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === "install") {
// First install β set defaults
chrome.storage.local.set({ enabled: true, count: 0 });
} else if (details.reason === "update") {
// Extension updated
console.log("Updated from", details.previousVersion);
}
});
// Listen for messages from popup or content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "GET_DATA") {
chrome.storage.local.get("count", (data) => {
sendResponse({ count: data.count ?? 0 });
});
return true; // Keep message channel open for async response
}
});
// Alarm for periodic tasks (replaces setInterval)
chrome.alarms.create("daily-check", { periodInMinutes: 60 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "daily-check") {
// Do periodic work
}
});
// Context menu
chrome.contextMenus.create({
id: "my-menu",
title: "My Extension Action",
contexts: ["selection"]
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === "my-menu") {
chrome.tabs.sendMessage(tab.id, {
type: "SELECTION",
text: info.selectionText
});
}
});
// Badge updates
chrome.action.setBadgeText({ text: "3" });
chrome.action.setBadgeBackgroundColor({ color: "#22c55e" });
V2 background page β V3 service worker
| V2 (Background Page) | V3 (Service Worker) |
|---|---|
background.page: "bg.html" | background.service_worker: "bg.js" |
| Persistent (always running) | Event-driven (can be terminated) |
| Full DOM access | No DOM β use offscreen documents |
setInterval / setTimeout | chrome.alarms |
XMLHttpRequest | fetch() |
window.localStorage | chrome.storage.local |
chrome.browserAction | chrome.action |
Content Scripts
Scripts that run in the context of web pages. Can access the DOM but have limited access to Chrome APIs.
Manifest declaration
json
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content.js"],
"css": ["styles.css"],
"run_at": "document_idle",
"all_frames": false,
"match_about_blank": false,
"world": "ISOLATED"
}
]
run_at values
| Value | When | Use Case |
|---|---|---|
document_idle | After DOMContentLoaded + idle (default) | Most cases β DOM is ready |
document_start | Before any DOM/CSS | Inject CSS before paint, intercept early |
document_end | After DOM, before subresources | Access DOM before images/iframes load |
world: "MAIN" vs "ISOLATED"
json
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content.js"],
"world": "ISOLATED" // Default β separate JS context, can use chrome.* APIs
},
{
"matches": ["https://*.example.com/*"],
"js": ["main-world.js"],
"world": "MAIN" // Shares page's JS context, NO chrome.* APIs
}
]
Use
"world": "MAIN" to access page's JavaScript objects (e.g., window.someFramework). Communicate back to the extension via window.postMessage.Dynamic injection (scripting API)
js
// Requires "scripting" permission + activeTab or host_permissions
chrome.scripting.executeScript({
target: { tabId: tabId },
files: ["content.js"]
});
// Inline function injection
chrome.scripting.executeScript({
target: { tabId: tabId },
func: (color) => {
document.body.style.backgroundColor = color;
},
args: ["#1a1a2e"]
});
// Insert CSS
chrome.scripting.insertCSS({
target: { tabId: tabId },
css: "body { border: 3px solid red; }"
});
// Or from file
chrome.scripting.insertCSS({
target: { tabId: tabId },
files: ["override.css"]
});
Content script β Service worker messaging
js
// content.js β send message to service worker
chrome.runtime.sendMessage({ type: "PAGE_DATA", url: location.href });
// Listen for messages from service worker
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "HIGHLIGHT") {
document.querySelectorAll(msg.selector).forEach(el => {
el.style.outline = "2px solid #22c55e";
});
}
});
// background.js β send to specific tab
chrome.tabs.sendMessage(tabId, { type: "HIGHLIGHT", selector: "h1" });
Action (Browser Action)
Configures the extension's toolbar icon and popup. Replaces Manifest V2's browser_action and page_action.
Basic configuration
json
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"default_title": "Click to open"
}
Icon as string shorthand
json
"action": {
"default_popup": "popup.html",
"default_icon": "icons/icon.png",
"default_title": "My Extension"
}
API methods
js
// Badge
chrome.action.setBadgeText({ text: "5" });
chrome.action.setBadgeBackgroundColor({ color: "#22c55e" });
chrome.action.setBadgeTextColor({ color: "#ffffff" }); // Chrome 110+
// Title
chrome.action.setTitle({ title: "3 new items" });
// Icon (can use ImageData for dynamic icons)
chrome.action.setIcon({ path: "icons/icon-active.png" });
// Enable/disable
chrome.action.enable(tabId);
chrome.action.disable(tabId);
// Open popup programmatically (user gesture required)
chrome.action.openPopup();
// Listen for clicks (only fires if no popup defined)
chrome.action.onClicked.addListener((tab) => {
// No default_popup set β handle click here
chrome.tabs.sendMessage(tab.id, { type: "TOGGLE" });
});
If
default_popup is set, chrome.action.onClicked will NOT fire. Remove default_popup if you need to handle clicks programmatically.Options Page
Full page
json
"options_page": "options.html"
Embedded in chrome://extensions
json
"options_ui": {
"page": "options.html",
"open_in_tab": true
}
Set
open_in_tab: true for a full-width options page. false embeds it inside the extensions page dialog.Commands & Shortcuts
Manifest
json
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+Y",
"mac": "Command+Shift+Y"
},
"description": "Open extension popup"
},
"custom-command": {
"suggested_key": {
"default": "Alt+Shift+G"
},
"description": "Run custom action"
}
}
Handle commands in service worker
js
chrome.commands.onCommand.addListener((command) => {
if (command === "custom-command") {
console.log("Custom command triggered");
}
// "_execute_action" is handled automatically β opens popup
});
Omnibox
Configuration
json
"omnibox": {
"keyword": "myext"
}
Handle omnibox input
js
// User types "myext search query" in address bar
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
suggest([
{ content: text + " option1", description: "First suggestion" },
{ content: text + " option2", description: "Second suggestion" }
]);
});
chrome.omnibox.onInputEntered.addListener((text, disposition) => {
// disposition: "currentTab" | "newForegroundTab" | "newBackgroundTab"
chrome.tabs.update({ url: "https://example.com/search?q=" + encodeURIComponent(text) });
});
Declarative Net Request
Manifest V3 replacement for webRequestBlocking. Define rules to block, redirect, or modify requests.
Manifest
json
"permissions": ["declarativeNetRequest"],
"declarative_net_request": {
"rule_resources": [
{
"id": "ruleset_1",
"enabled": true,
"path": "rules/rules.json"
}
]
},
"host_permissions": [
"*://*.example.com/*"
]
Static rules file (rules/rules.json)
json
[
{
"id": 1,
"priority": 1,
"action": {
"type": "block"
},
"condition": {
"urlFilter": "||ads.example.com^",
"resourceTypes": ["script", "image", "xmlhttprequest"]
}
},
{
"id": 2,
"priority": 2,
"action": {
"type": "redirect",
"redirect": {
"url": "https://cdn.example.com/clean.js"
}
},
"condition": {
"urlFilter": "||tracker.example.com/analytics.js",
"resourceTypes": ["script"]
}
},
{
"id": 3,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{ "header": "X-Custom-Header", "operation": "set", "value": "my-value" }
],
"responseHeaders": [
{ "header": "x-frame-options", "operation": "remove" }
]
},
"condition": {
"urlFilter": "*://example.com/*",
"resourceTypes": ["main_frame", "sub_frame"]
}
}
]
Dynamic rules via API
js
chrome.declarativeNetRequest.updateDynamicRules({
addRules: [{
id: 1001,
priority: 1,
action: { type: "block" },
condition: {
urlFilter: "||blocked.example.com^",
resourceTypes: ["script"]
}
}],
removeRuleIds: [1001] // Remove previous version
});
Web Accessible Resources
Resources in your extension that web pages can access.
json
"web_accessible_resources": [
{
"resources": ["images/icon.png", "styles/inject.css"],
"matches": ["https://*.example.com/*"]
},
{
"resources": ["lib/helper.js"],
"extension_ids": ["other-extension-id"],
"matches": [""]
}
]
Access from page:
chrome-extension://YOUR_EXTENSION_ID/images/icon.png. In content scripts, use chrome.runtime.getURL("images/icon.png") to get the full URL.Common Patterns
Popup β Service Worker β Content Script chain
js
// popup.js
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
chrome.tabs.sendMessage(tabs[0].id, { type: "ACTION", payload: "go" });
});
// background.js (relay if needed)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "RELAY") {
chrome.tabs.sendMessage(msg.tabId, msg.payload, sendResponse);
return true;
}
});
// content.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "ACTION") {
// Do something on the page
sendResponse({ success: true });
}
});
Content script β Service Worker with response
js
// content.js
chrome.runtime.sendMessage(
{ type: "FETCH_DATA", url: "https://api.example.com/data" },
(response) => {
console.log("Got:", response.data);
}
);
// background.js (has host_permissions for the API)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "FETCH_DATA") {
fetch(msg.url)
.then(r => r.json())
.then(data => sendResponse({ data }))
.catch(err => sendResponse({ error: err.message }));
return true; // Required for async sendResponse
}
});
Side panel extension
json
{
"manifest_version": 3,
"name": "Side Panel Extension",
"version": "1.0.0",
"permissions": ["sidePanel", "activeTab", "storage"],
"side_panel": {
"default_path": "sidepanel.html"
},
"action": {
"default_title": "Open Side Panel"
},
"background": {
"service_worker": "background.js"
}
}
js
// background.js β Open side panel on action click
chrome.action.onClicked.addListener(async (tab) => {
await chrome.sidePanel.open({ tabId: tab.id });
});
Storage with schema
js
// defaults.js
const DEFAULTS = {
theme: "dark",
enabled: true,
recentItems: [],
maxItems: 50
};
// Install defaults
chrome.runtime.onInstalled.addListener(() => {
chrome.storage.local.get(Object.keys(DEFAULTS), (data) => {
const missing = {};
for (const [key, value] of Object.entries(DEFAULTS)) {
if (!(key in data)) missing[key] = value;
}
if (Object.keys(missing).length) {
chrome.storage.local.set(missing);
}
});
});
// Storage change listener
chrome.storage.onChanged.addListener((changes, area) => {
if (area === "local" && changes.theme) {
console.log("Theme changed to:", changes.theme.newValue);
}
});
// Sync storage (synced across devices, 100KB limit)
chrome.storage.sync.set({ theme: "dark" });
chrome.storage.sync.get("theme", (data) => console.log(data.theme));
// Session storage (cleared on browser close)
chrome.storage.session.set({ tempData: "value" });
Alarms β periodic background tasks
js
// Create alarm β minimum period is 1 minute in MV3
chrome.alarms.create("sync", { periodInMinutes: 5 });
// One-time alarm
chrome.alarms.create("delayed-action", { delayInMinutes: 2 });
// Listen
chrome.alarms.onAlarm.addListener((alarm) => {
switch (alarm.name) {
case "sync":
// fetch latest data
break;
case "delayed-action":
// one-time task
chrome.alarms.clear("delayed-action");
break;
}
});
Internationalization (_locales)
json
// _locales/en/messages.json
{
"extName": {
"message": "My Extension",
"description": "Extension name"
},
"popupTitle": {
"message": "Click to start",
"description": "Popup title text"
},
"itemCount": {
"message": "$COUNT$ items found",
"description": "Shows item count",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
}
}
}
}
json
// manifest.json with i18n
{
"name": "__MSG_extName__",
"description": "__MSG_extDesc__",
"default_locale": "en"
}
js
// In JS
chrome.i18n.getMessage("extName");
chrome.i18n.getMessage("itemCount", "5");
V2 β V3 Migration Checklist
| V2 | V3 | Action |
|---|---|---|
"manifest_version": 2 | "manifest_version": 3 | Change version |
background.scripts or background.page | background.service_worker | Migrate to service worker |
browser_action / page_action | action | Replace key name |
chrome.browserAction | chrome.action | Update API calls |
permissions includes hosts | host_permissions | Move host patterns |
webRequestBlocking | declarativeNetRequest | Use declarative rules |
content_security_policy (string) | content_security_policy.extension_pages | Change to object |
Code in <script> tags in HTML | Separate JS files | Move inline code to files |
chrome.extension.getURL | chrome.runtime.getURL | Update method |
chrome.extension.sendRequest | chrome.runtime.sendMessage | Update method |
| Persistent background | Event-driven service worker | Replace setInterval with alarms |
DOMParser in background | Offscreen document | Use chrome.offscreen |
CSP format change
json
// V2 (string)
"content_security_policy": "script-src 'self'; object-src 'self'"
// V3 (object)
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'",
"sandbox": "sandbox allow-scripts allow-forms; script-src 'self'"
}
web_accessible_resources format change
json
// V2 (flat array)
"web_accessible_resources": ["images/icon.png", "lib/inject.js"]
// V3 (array of objects)
"web_accessible_resources": [
{
"resources": ["images/icon.png", "lib/inject.js"],
"matches": ["https://*.example.com/*"]
}
]