What Is a Chrome Extension
A Chrome extension is a small software program that customizes the Chrome browsing experience. Extensions can modify web pages, add UI elements to the browser, intercept network requests, manage tabs, and interact with Chrome's built-in features. They are built with standard web technologies: HTML, CSS, and JavaScript.
As of 2026, all new Chrome extensions must use Manifest V3 (MV3), the latest extension platform. MV3 introduced significant architectural changes from the previous MV2 format, including the replacement of persistent background pages with event-driven service workers, the shift from the webRequest API to the declarativeNetRequest API for network modification, and stricter content security policies. These changes improve security, performance, and privacy at the cost of some flexibility that MV2 developers had enjoyed.
This guide walks through building a complete Chrome extension from scratch using MV3. By the end, you will have a working extension that you can load into Chrome for testing and publish to the Chrome Web Store.
Prerequisites
To follow this guide, you need:
- Google Chrome (version 134 or later recommended)
- A text editor or IDE (VS Code, WebStorm, or any editor you prefer)
- Basic knowledge of HTML, CSS, and JavaScript
- Familiarity with JSON format (the manifest file is JSON)
- A Google account (for Chrome Web Store publishing)
No build tools, bundlers, or frameworks are required for a basic extension. You can build everything with plain files. For larger projects, you may choose to add a bundler like Vite or Webpack, but that is optional and not covered in this introductory guide.
If you need to validate your JSON manifest file, our JSON Formatter tool checks syntax and formats JSON with proper indentation.
Project Structure
A Chrome extension is a directory containing specific files that Chrome knows how to read. The minimum required file is manifest.json. A typical extension project looks like this:
manifest.json
service-worker.js
popup/
popup.html
popup.css
popup.js
content/
content.js
content.css
icons/
icon-16.png
icon-32.png
icon-48.png
icon-128.png
Each file serves a specific purpose. The manifest declares the extension's metadata and capabilities. The service worker runs in the background and handles events. Content scripts run in the context of web pages. The popup provides a UI when the user clicks the extension icon. Icons are required at multiple sizes for display in the toolbar, extensions page, and Chrome Web Store.
The Manifest File (manifest.json)
The manifest is the most important file in your extension. It tells Chrome everything about the extension: its name, version, permissions, and which scripts to load. Here is a complete manifest for a typical extension:
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A brief description of what the extension does.",
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
},
"default_title": "Click to open"
},
"background": {
"service_worker": "service-worker.js"
},
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content/content.js"],
"css": ["content/content.css"],
"run_at": "document_idle"
}
],
"permissions": ["storage", "activeTab"],
"host_permissions": ["https://*.example.com/*"]
}
Let us walk through the key fields:
manifest_version: Must be 3. This tells Chrome to use the MV3 platform.nameandversion: Your extension's display name and semantic version number.description: A short description (up to 132 characters) shown in the Chrome Web Store and the extensions management page.icons: PNG icons at 16, 32, 48, and 128 pixel sizes. The 128px icon is used in the Chrome Web Store listing.action: Defines the toolbar icon behavior.default_popupspecifies the HTML file shown when the icon is clicked.background: Registers the service worker script.content_scripts: Declares scripts to inject into matching web pages.permissions: API permissions the extension needs (storage, tabs, alarms, etc.).host_permissions: URL patterns for sites the extension can access.
For viewing and editing JSON structures interactively, our JSON Viewer tool provides a tree view with collapsible nodes.
Background Service Worker
The service worker is the central event handler for your extension. Unlike MV2 background pages that ran persistently, MV3 service workers start when an event fires and stop when idle. This means you cannot rely on global variables persisting between events. Use the chrome.storage API to persist state.
Here is a basic service worker that responds to the extension being installed and handles messages from other parts of the extension:
// service-worker.js
// Runs when the extension is first installed or updated
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
// Set default settings on first install
chrome.storage.local.set({
enabled: true,
theme: 'dark',
count: 0
});
}
});
// Listen for messages from popup or content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_COUNT') {
chrome.storage.local.get('count', (data) => {
sendResponse({ count: data.count || 0 });
});
return true; // Keep the message channel open for async response
}
if (message.type === 'INCREMENT') {
chrome.storage.local.get('count', (data) => {
const newCount = (data.count || 0) + 1;
chrome.storage.local.set({ count: newCount }, () => {
sendResponse({ count: newCount });
});
});
return true;
}
});
// React to tab updates
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete' && tab.url) {
// Tab finished loading, perform any needed action
console.log('Tab loaded:', tab.url);
}
});
Important patterns to note in service worker development:
- Return
truefromonMessagelisteners when using asynchronoussendResponse. Without this, the message channel closes before the async operation completes. - Do not use
setTimeoutorsetIntervalfor long delays. The service worker may be terminated before the timer fires. Use thechrome.alarmsAPI instead. - Store all state in
chrome.storage.localorchrome.storage.sessionrather than global variables. - Keep the service worker lean. Avoid importing large libraries that increase startup time.
Content Scripts
Content scripts run in the context of web pages that match the URL patterns defined in your manifest. They can read and modify the DOM of the page but run in an isolated world, meaning they cannot access JavaScript variables or functions defined by the page itself (and vice versa).
// content/content.js
// This script runs on pages matching the manifest's content_scripts pattern
function highlightLinks() {
const links = document.querySelectorAll('a[href]');
links.forEach(link => {
// Add a custom data attribute to track processed links
if (!link.dataset.extensionProcessed) {
link.dataset.extensionProcessed = 'true';
link.style.outline = '2px solid #00ff88';
}
});
}
// Run when the DOM is ready
highlightLinks();
// Watch for dynamically added content
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
highlightLinks();
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Listen for messages from the popup or service worker
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'TOGGLE_HIGHLIGHTS') {
const links = document.querySelectorAll('a[data-extension-processed]');
links.forEach(link => {
link.style.outline = message.enabled ? '2px solid #00ff88' : '';
});
sendResponse({ success: true });
}
});
Content scripts have access to a limited set of Chrome APIs: runtime (for messaging), storage, and i18n. For anything else, the content script must send a message to the service worker, which has full API access.
The run_at property in the manifest controls when the content script executes. Options are document_start (before the DOM is constructed), document_end (after the DOM is complete but before images and subframes load), and document_idle (after the page finishes loading, the default).
Popup UI
The popup is a small HTML page that appears when the user clicks your extension's toolbar icon. It has its own execution context, separate from both the page and the service worker. The popup closes whenever the user clicks outside it.
<!-- popup/popup.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="popup-container">
<h1>My Extension</h1>
<p>Links found: <span id="count">0</span></p>
<label class="toggle">
<input type="checkbox" id="enabled" checked>
<span>Enable highlighting</span>
</label>
<button id="refresh">Refresh Count</button>
</div>
<script src="popup.js"></script>
</body>
</html>
/* popup/popup.css */
body {
width: 280px;
padding: 16px;
font-family: 'Inter', sans-serif;
background: #0a0a0f;
color: #e0e0e8;
margin: 0;
}
h1 {
font-size: 1.2rem;
color: #fff;
margin: 0 0 12px;
}
.toggle {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0;
cursor: pointer;
}
button {
width: 100%;
padding: 10px;
background: #00ff88;
color: #0a0a0f;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
}
button:hover {
background: #00cc6e;
}
// popup/popup.js
document.addEventListener('DOMContentLoaded', () => {
const countEl = document.getElementById('count');
const enabledEl = document.getElementById('enabled');
const refreshBtn = document.getElementById('refresh');
// Load saved state
chrome.storage.local.get(['enabled', 'count'], (data) => {
enabledEl.checked = data.enabled !== false;
countEl.textContent = data.count || 0;
});
// Toggle highlighting
enabledEl.addEventListener('change', () => {
const enabled = enabledEl.checked;
chrome.storage.local.set({ enabled });
// Send message to active tab's content script
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]) {
chrome.tabs.sendMessage(tabs[0].id, {
type: 'TOGGLE_HIGHLIGHTS',
enabled
});
}
});
});
// Refresh count from service worker
refreshBtn.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'GET_COUNT' }, (response) => {
if (response) {
countEl.textContent = response.count;
}
});
});
});
Note that inline JavaScript is not allowed in extension HTML files due to MV3's Content Security Policy. All JavaScript must be in separate .js files loaded via <script src="..."> tags. Inline event handlers like onclick="" are also prohibited. Use addEventListener instead.
Understanding Permissions
Permissions are central to how Chrome extensions work. Every API or capability your extension uses must be declared in the manifest. There are three categories:
API permissions (permissions array): these grant access to specific Chrome APIs. Common ones include:
storage- read and write to chrome.storageactiveTab- temporary access to the active tab when the user clicks the extension icontabs- access tab URLs and titles (without this, you only get tab IDs)alarms- schedule recurring tasksnotifications- display desktop notificationscontextMenus- add items to the right-click context menubookmarks- read and modify bookmarks
Host permissions (host_permissions array): URL patterns that define which websites the extension can interact with. For example, "https://*.google.com/*" grants access to all Google subdomains. Use "<all_urls>" only when you genuinely need access to every website, as it triggers a strong permission warning and reduces install rates.
Optional permissions (optional_permissions array): permissions that are not granted at install time but can be requested at runtime using chrome.permissions.request(). This pattern is preferable for features that not every user needs, as it keeps the initial permission warning minimal.
Tip: Use activeTab instead of broad host permissions whenever possible. It grants temporary access to the current tab only when the user actively engages with your extension (by clicking the icon), and it does not trigger a permission warning dialog. This is the most user-friendly permission model.
Storage and State Management
MV3 service workers are ephemeral, so persistent storage is critical. Chrome provides several storage mechanisms:
chrome.storage.local stores data locally with a default limit of 10 MB (increase to unlimited with the unlimitedStorage permission). Data persists until the extension is uninstalled. This is the primary storage mechanism for most extensions.
chrome.storage.sync syncs data across the user's Chrome instances. Limited to 100 KB total and 8 KB per item. Use this for user preferences that should follow them across devices.
chrome.storage.session stores data only for the current browser session. Data is cleared when Chrome closes. This is useful for temporary state that the service worker needs to access across event handlers but should not persist long-term.
// Writing to storage
chrome.storage.local.set({
settings: { theme: 'dark', fontSize: 14 },
lastUpdated: Date.now()
});
// Reading from storage
chrome.storage.local.get(['settings', 'lastUpdated'], (result) => {
console.log(result.settings); // { theme: 'dark', fontSize: 14 }
console.log(result.lastUpdated); // timestamp
});
// Listening for changes
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local' && changes.settings) {
console.log('Old settings:', changes.settings.oldValue);
console.log('New settings:', changes.settings.newValue);
}
});
Messaging Between Components
Chrome extensions have multiple execution contexts (service worker, popup, content scripts, options page) that need to communicate. The messaging system provides this communication channel.
One-time messages use chrome.runtime.sendMessage() to send and chrome.runtime.onMessage.addListener() to receive. For content scripts, use chrome.tabs.sendMessage(tabId, message) to send messages from the service worker or popup to a specific tab.
// From popup to service worker
chrome.runtime.sendMessage(
{ type: 'SAVE_DATA', payload: { key: 'value' } },
(response) => {
console.log('Service worker responded:', response);
}
);
// From service worker to content script in a specific tab
chrome.tabs.sendMessage(tabId,
{ type: 'UPDATE_UI', data: { color: '#00ff88' } },
(response) => {
console.log('Content script responded:', response);
}
);
For long-lived connections (streaming data, for example), use chrome.runtime.connect() to establish a port-based connection. Ports remain open until explicitly disconnected and support bidirectional messaging.
Loading and Testing Your Extension
To test your extension during development:
- Open Chrome and navigate to
chrome://extensions/. - Enable "Developer mode" using the toggle in the top-right corner.
- Click "Load unpacked" and select your extension's directory.
- Your extension appears in the list with its icon in the toolbar (you may need to pin it from the puzzle piece menu).
After making changes to your code, click the refresh icon on your extension's card in chrome://extensions/ to reload it. Changes to the service worker and content scripts require this reload. Changes to the popup HTML/CSS/JS take effect the next time you open the popup.
For debugging each component:
- Popup: right-click the extension icon and select "Inspect Popup" to open DevTools for the popup context.
- Service worker: on the extension card in
chrome://extensions/, click the "Service Worker" link to open its DevTools console. - Content scripts: open the regular page DevTools (F12) and look in the Sources panel under "Content Scripts" for your injected files.
The chrome://extensions/ page also shows any errors encountered by your extension. A red error badge appears on the extension card when errors are logged. Click "Errors" to see the details.
Publishing to the Chrome Web Store
When your extension is ready for public release, follow these steps to publish it:
- Register as a Chrome Web Store developer at the Chrome Web Store Developer Console. The one-time registration fee is $5 USD.
- Create a ZIP file of your extension directory. The ZIP should contain the files directly (not a parent folder).
- In the Developer Console, click "New Item" and upload the ZIP file.
- Fill in the store listing details: detailed description, at least one screenshot (1280x800 or 640x400), category, and language.
- If your extension uses certain permissions (like
activeTab,tabs, or host permissions), you need to provide a justification for each in the "Privacy" tab. Google reviews these justifications during the review process. - Submit for review. New extensions typically take 1-3 business days to be reviewed. Updates to existing extensions are usually faster.
Before submission, make sure your extension meets the Chrome Web Store policies. Common rejection reasons include: requesting unnecessary permissions, lacking a privacy policy (required if you handle user data), misleading descriptions, and quality issues like broken functionality.
For generating proper JSON-LD structured data for your extension's landing page, our Schema Generator creates valid schema.org markup. Before deployment, use the JS Minifier to reduce your extension's file size.
Related Tools on Zovo Tools
Frequently Asked Questions
Yes. Chrome extensions are built with HTML, CSS, and JavaScript. You need a working knowledge of JavaScript to handle extension logic, event listeners, Chrome API calls, and DOM manipulation in content scripts. Familiarity with asynchronous JavaScript (Promises and async/await) is particularly important since most Chrome extension APIs are asynchronous.
Manifest V3 (MV3) replaces persistent background pages with service workers that run only when needed, replaces the webRequest blocking API with the declarativeNetRequest API for network modification, requires host permissions to be declared separately, and enforces stricter content security policies. MV3 is now required for all new Chrome Web Store submissions, and existing MV2 extensions must migrate.
Publishing to the Chrome Web Store requires a one-time developer registration fee of $5 USD. There are no ongoing fees for hosting or distribution. Google reviews each submission, which typically takes 1-3 business days for new extensions and a few hours for updates to existing ones.
Only if the user grants permission. In Manifest V3, host permissions are requested at install time or at runtime. Users can choose to grant access to all sites, specific sites, or only on click. Extensions that request fewer permissions generally have higher install rates because users are more comfortable granting limited access.
For the popup UI, right-click the extension icon and select "Inspect Popup" to open DevTools for the popup context. For the service worker, go to chrome://extensions, find your extension, and click "Service Worker" to open its DevTools console. For content scripts, use the regular page DevTools and look for your script files in the Sources panel under the Content Scripts section.