Browser Extensions
How to Build a Chrome Extension with Manifest V3: A Practical Guide
A step-by-step walk through a minimal Manifest V3 extension, then the pitfalls that catch teams out: short-lived service workers, permissions and store review.
By Prolificrew Engineering · · 4 min read
In this article
A browser extension is a small web application that runs inside the browser and can interact with the pages people visit. Chrome’s current extension platform is Manifest V3, which Edge and other Chromium browsers share and which Firefox supports with some differences. We build and publish extensions ourselves, including Format Pro on the Chrome Web Store, so this guide focuses on what you need to know to get a real extension working and through review.
What an extension is made of
- manifest.json: the declaration of your extension’s name, version, permissions and entry points.
- Service worker: the background script. In Manifest V3 it starts when needed and stops when idle, so it cannot keep state in memory.
- Content scripts: JavaScript injected into matching web pages, able to read and change the page’s DOM.
- Popup or options page: normal HTML pages for your own interface.
Step 1: the manifest
Create a folder and add a manifest.json. This minimal example registers a popup, a background service worker, a content script for one site, and two permissions:
{
"manifest_version": 3,
"name": "Page Title Logger",
"version": "1.0.0",
"description": "Logs the title of pages you visit on example.com.",
"action": { "default_popup": "popup.html" },
"background": { "service_worker": "background.js" },
"permissions": ["storage"],
"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["content.js"]
}
]
}Keep permissions as narrow as possible. Ask for specific host patterns instead of <all_urls>, and prefer activeTab (access only after the user clicks your extension) over broad host access whenever it will do the job. Narrow permissions pass review faster and make users more willing to install.
Step 2: the content script
A content script runs in an isolated environment on the page. It can read the DOM but does not share JavaScript variables with the page itself. Here it sends the page title to the service worker:
// content.js
chrome.runtime.sendMessage({
type: "PAGE_VISITED",
title: document.title,
url: location.href
});Step 3: the service worker
The service worker receives the message and saves it with the storage API. Because the worker can be shut down at any time, anything you need later must go into storage, not into a variable.
// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== "PAGE_VISITED") return;
chrome.storage.local.get({ visits: [] }).then(({ visits }) => {
visits.push({ title: message.title, url: message.url, at: Date.now() });
// keep the list from growing without limit
return chrome.storage.local.set({ visits: visits.slice(-50) });
}).then(() => sendResponse({ ok: true }));
return true; // keep the message channel open for the async response
});Note the return true. When you respond asynchronously from a message listener, you must return true synchronously or the channel closes before your reply is sent. It is one of the most common Manifest V3 bugs.
Also remember that chrome.storage.local needs the storage permission. We used local here; chrome.storage.sync roughly follows the user across their signed-in browsers but has much smaller quotas.
Step 4: the popup
<!-- popup.html -->
<!doctype html>
<html>
<body style="min-width:280px;font:14px system-ui">
<h1 style="font-size:16px">Recent pages</h1>
<ul id="list"></ul>
<script src="popup.js"></script>
</body>
</html>// popup.js
chrome.storage.local.get({ visits: [] }).then(({ visits }) => {
const list = document.getElementById("list");
for (const v of visits.slice().reverse()) {
const li = document.createElement("li");
li.textContent = v.title;
list.appendChild(li);
}
});Use textContent rather than innerHTML when displaying page-derived data. Page titles are untrusted input, and Manifest V3’s default content security policy exists to stop script injection.
Step 5: load it and test
- Open
chrome://extensionsand switch on Developer mode. - Click Load unpacked and choose your folder.
- Visit a matching page, then click the extension’s toolbar icon.
- After code changes, press the reload icon on the extension card. For service worker problems, click the service worker link on the card to open its DevTools console.
What changed from Manifest V2
| Area | Manifest V2 | Manifest V3 |
|---|---|---|
| Background code | Persistent background page | Event-driven service worker that stops when idle |
| Remotely hosted code | Allowed with restrictions | Not allowed; all logic must ship in the package |
| Blocking network requests | webRequest blocking | declarativeNetRequest rules |
| Host permissions | Mixed into permissions | Separate host_permissions field |
| Promises | Callbacks mostly | Promise support in most APIs |
Common pitfalls
- Storing state in service worker variables. It will be lost. Use
chrome.storageor IndexedDB. - Using `setTimeout` or `setInterval` for scheduled work. Timers die with the worker. Use the
chrome.alarmsAPI (check the documentation for the current minimum interval). - Over-asking for permissions. Reviewers will ask you to justify each one; users see the install warning.
- Fetching and running remote scripts. Prohibited. Bundle your code, and fetch only data.
- Forgetting the host page. A content script that does heavy work on every page load will make sites feel slow. Run it only where needed and defer expensive work.
Publishing on the Chrome Web Store
You need a developer account (with a one-time registration fee), a zipped build of your extension, store assets such as icons and screenshots, and accurate answers about what data you collect. Google’s review checks that the extension does a single clear thing, that permissions match the description, and that your privacy disclosures are truthful. Write the permission justifications plainly: say what each permission is used for and why a narrower one will not do. Expect the first review to take longer than later updates.
Firefox supports the WebExtensions API with a Manifest V3 implementation that differs in details such as background scripts. If you plan to ship to both, test early on both browsers and keep browser-specific code behind a small abstraction.
Next steps
From here, the usual path is to add a build step (we like TypeScript with a bundler), an options page that stores user settings, and automated tests for your content-script logic. If you would rather have a team that has been through store review do it with you, see our browser extension development service or talk to us.