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.
📋 Table of Contents
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.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment