How to make a Chrome extension (Manifest V3, step by step)
Maksim Norkin (opens in a new tab)Product
· Updated · 9 min read
A site you use every day is missing one thing. Here's how to add it yourself with a Manifest V3 extension, from the first manifest line to the Chrome Web Store.
char-counter
Most extensions start the same way. A site you use every day is missing one thing, and you'd rather add it yourself than wait for it. This guide builds a small extension that does that: it adds a character count under every text box on one site, with an on/off switch in the toolbar. Along the way it covers every part you'll need for bigger ideas, and then debugging and publishing.
You need Chrome, a text editor and an empty folder. There's no build step and no framework.
- The manifest (manifest.json) names the extension and declares everything else in it: its files, its permissions, and the sites it runs on.
- Files in the page, listed under
content_scriptsin the manifest, run inside the pages you choose. They can read and change the page's DOM. - A service worker handles browser events in the background, such as an install, a storage change or an alarm.
- UI pages are plain HTML: a popup that opens from the toolbar button, a side panel, or an options page.
The example uses all four. Make a folder called char-counter and put each file below in it.
char-counter/
- manifest.jsonNames the extension, declares everything
- content.jsRuns in the pages the manifest matches
- popup.htmlThe popup the toolbar button opens
- popup.jsThe popup’s on/off switch
- background.jsThe service worker
{
"manifest_version": 3,
"name": "Character Counter",
"version": "1.0",
"description": "Shows a character count under the text boxes on one site.",
"action": { "default_popup": "popup.html" },
"background": { "service_worker": "background.js" },
"permissions": ["storage"],
"content_scripts": [
{
"matches": ["https://support.example.com/*"],
"js": ["content.js"]
}
]
}The manifest_version field has to be 3. The version field is one to four integers separated by dots, and Chrome won't load the extension if it's anything else. The matches list names the pages content.js runs on. Replace support.example.com with the site you want to fix, and keep it to that one site for now. The permissions section below explains why.
- 1Turn on Developer modeAt the top right
- 2Load unpackedPick the char-counter folder, once
- 3Reload after each editAnd reload the page for content.js
Open chrome://extensions, turn on Developer mode at the top right, click Load unpacked and select your folder. The extension shows up in the list and in the toolbar's extensions menu. Pin it so its button stays visible.
You only load it once. After each edit, click the reload icon on the extension's card. That's needed for changes to the manifest, the service worker and the files under content_scripts. A popup picks up its changes the next time it opens. A changed content.js also needs a reload of the page it runs on, and everyone forgets that step at least once.
// Runs on every page the manifest matches. It shares the page's DOM,
// but not the page's own JavaScript variables.
function addCounter(box) {
if (box.dataset.charCounter) return;
box.dataset.charCounter = 'on';
const counter = document.createElement('div');
counter.style.cssText = 'font: 12px system-ui; color: #666; margin-top: 4px';
const update = () => {
counter.textContent = `${box.value.length} characters`;
};
box.addEventListener('input', update);
box.after(counter);
update();
}
function addCounters() {
document.querySelectorAll('textarea').forEach(addCounter);
}
chrome.storage.sync.get({ enabled: true }).then(({ enabled }) => {
if (!enabled) return;
addCounters();
// Many sites add text boxes after the page has loaded, so watch for new ones.
new MutationObserver(addCounters).observe(document.body, {
childList: true,
subtree: true,
});
});Like every file under content_scripts, this one runs in an isolated world. It sees and changes the same DOM the page does, but it can't see the page's variables, and the page can't see the file's.
It can also call only a few extension APIs directly: chrome.storage, chrome.runtime messaging, chrome.i18n and a handful of others. For anything else, such as the toolbar badge or the list of tabs, it sends a message to the service worker, and the service worker makes the call.
content.js runs at document_idle by default, after the page has loaded. Set run_at in the manifest if you need it to run earlier.
<!doctype html>
<html>
<body style="width: 220px; font: 14px system-ui">
<label><input type="checkbox" id="enabled" /> Show character counts</label>
<script src="popup.js"></script>
</body>
</html>const box = document.getElementById('enabled');
chrome.storage.sync.get({ enabled: true }).then(({ enabled }) => {
box.checked = enabled;
});
box.addEventListener('change', () => {
chrome.storage.sync.set({ enabled: box.checked });
});popup.js is its own file because extension pages don't run inline <script> blocks.
The popup closes as soon as you click anywhere else in the browser, and nothing can keep it open, so anything you want to keep goes into chrome.storage. The storage.sync area syncs across a person's browsers when Chrome sync is on, and holds about 100 KB in total, 8 KB per item. The storage.local area holds 10 MB and doesn't sync. Both need the "storage" permission, which the manifest already declares.
The setting takes effect on the next page load, because content.js reads it once when it starts. To turn the counters off right away, have it listen to chrome.storage.onChanged as well.
// Listeners go at the top level, so Chrome registers them every time
// it starts the worker.
async function updateBadge() {
const { enabled } = await chrome.storage.sync.get({ enabled: true });
await chrome.action.setBadgeText({ text: enabled ? '' : 'off' });
}
chrome.runtime.onInstalled.addListener(updateBadge);
chrome.runtime.onStartup.addListener(updateBadge);
chrome.storage.onChanged.addListener((changes) => {
if (changes.enabled) updateBadge();
});The toolbar button now shows "off" while the counters are switched off.
Most Manifest V3 surprises come from the service worker, because it isn't always running. Chrome starts it when one of its events fires and stops it after 30 seconds with no events and no extension API calls. When it stops, its global variables are lost. So:
- Register every listener at the top level, as in the file above. Never register one inside a callback or after an
await: Chrome needs every listener in place when the worker first runs, so it can deliver the event that started it. - Keep state in chrome.storage, not in variables.
- Use chrome.alarms for anything periodic. A setInterval timer stops when the worker does.
Each permission and each site in your manifest can add a warning to the install dialog, and every warning costs you installs. Three habits keep the list short.
List only the sites you need. A content_scripts entry for https://support.example.com/* tells people exactly what it touches. <all_urls> or https://*/* asks for every site, the install warning says as much, and the Chrome Web Store lists broad host permissions among the things that can make review take longer.
Use activeTab for "do something to this page". If the extension only acts when someone clicks its button, a context-menu item or a keyboard shortcut, activeTab gives it the current tab until they go to another site or close the tab. It adds no warning at install.
Ask later, from a click. List sites under optional_host_permissions and call chrome.permissions.request() when a site is needed. The call only works inside a user gesture, such as a click handler:
button.addEventListener('click', async () => {
const granted = await chrome.permissions.request({
origins: ['https://tickets.example.com/*'],
});
// true if the person said yes in Chrome's dialog
});A runtime grant doesn't add content.js to that site by itself. Register it with chrome.scripting.registerContentScripts(), or inject it into one page with chrome.scripting.executeScript(). Both need the "scripting" permission.
We made the same choice in Tabless, the browser extension we're building at Wix. It's designed to hold no site access at install and to ask for one site the first time you let it read a page there. That's more dialogs than one broad grant, but each one names the site you're about to allow.
Errors keeps runtime errors, warnings and console.error. Not console.log.
Each part has its own console.
- Service worker: on chrome://extensions, click the link next to Inspect views. The worker stays awake while those DevTools are open, so close them before you test anything that depends on it stopping.
- Popup: open it, right-click inside it and choose Inspect.
content.js: open the page's own DevTools, and in the Console switch the dropdown that says top to your extension.- Errors: the Errors button on the extension's card turns red when something goes wrong. It records runtime errors, console.warn and console.error, but not console.log.
- 1Register as a developerA one-time fee. The email can’t change later.
- 2Zip the foldermanifest.json at the root of the zip
- 3Write the listingDescription, icons, screenshots
- 4Privacy practicesOne narrow purpose, a reason per permission
- 5Submit for reviewA few days, sometimes a few weeks
Everything it runs ships in the zip. Fetching data is fine.
Once it works, you can keep it unpacked for yourself or publish it.
- Register as a developer in the Chrome Web Store developer dashboard. There's a one-time registration fee, and you'll have to verify a contact email. The developer email can't be changed later, so pick it carefully.
- Zip the folder with manifest.json at the root, and upload the zip.
- Write the listing: a description, icons and screenshots.
- Fill in the privacy practices: the extension's single purpose, which the store's policy says must be "narrow and easy to understand", a justification for each permission, and what data the extension uses.
- Submit it for review. Most reviews finish within a few days, but some take a few weeks. New developers, new extensions, broad host permissions and large changes all add time.
Plan for one store rule before you design anything: everything your extension executes has to ship inside the package. Manifest V3 doesn't allow anything executable to be loaded from a server, whether JS or WebAssembly. Fetching data, such as JSON, is fine.
A lot of what you'll find online was written for Manifest V2. Chrome 138 turned Manifest V2 extensions off for every user, and the Chrome Web Store removed the last of them on 31 August 2026. You're reading an old guide if it uses:
- "manifest_version": 2, "browser_action" or "page_action", which are now "action".
"background": { "scripts": [...] }, a background page that runs all the time. It's now a service worker that stops.- chrome.extension.getBackgroundPage(), or globals the background is expected to keep.
- inline
<script>tags in popups, or JS fetched from a server and run witheval.
chrome.* works in every version that runs Manifest V3. Since Chrome 148 you can also use the standard browser.* namespace, which Chrome's own docs now use in their examples.
The counter above is about as small as a useful extension gets, and it's still real work. Here is what it took, and what it keeps taking:
- Five files before it does anything. A manifest, a file for the page, a popup and its own file, and a service worker, each with rules of its own about where it runs and what it may call.
- A setup step for each browser. Developer mode and Load unpacked on every machine you use it on, and a managed work browser may not allow Developer mode at all.
- Two reloads for every change. The extension's card, then the page it runs on, then checking a console for whichever part broke.
- Fixes when the site changes. The extension depends on how the site's pages are built today. When the site redesigns a page or renames an element, it can stop working without an error, and you find out when the counter is gone.
- Permissions and reviews. Every site and permission you add is a warning someone has to accept, a justification in the privacy practices, and a reason for a longer review. Each update is reviewed again, and one that adds a new permission warning turns the extension off for existing users until they accept.
For one small fix on one site, just for you, a bookmarklet is less work still: one link in your bookmarks bar, with no manifest and no store. The extension that gets heavy is the one that reads several sites you're signed in to and acts on them, because each site needs its own logic, and each site can change.
That's the case we're building Tabless for. Tabless is a browser extension that builds what you ask for from the sites you allow; nothing is typed or submitted without your OK. It isn't open yet. The idea is that you say what you want in plain words, such as an export, a view across several sites, or a row button that opens, fills in and submits a page after you click it, and there is nothing to load unpacked, reload or put through store review: Tabless is the one extension, and what you build lives inside it.
It's designed to ask before it reads each site, and what it reads for a build goes to Tabless's server and an AI provider, so it's for pages you're comfortable sharing that way. You'll sign in with a Wix account.
An AI assistant can draft the files in this guide. Check what it gives you against the outdated-tutorial list above, because many answers still lean on Manifest V2. You still load it unpacked, test each part in its own console, keep its permissions narrow, and fix it when the site changes.
Manifest V3 is the version of the extension platform Chrome runs today. The background page became a service worker that stops when idle, browser_action and page_action became action, and everything an extension executes has to ship inside its package. Chrome 138 turned Manifest V2 extensions off, and the Chrome Web Store removed the last of them on 31 August 2026.
Loading an extension unpacked for yourself costs nothing. Publishing it on the Chrome Web Store needs a developer account, and registering one has a one-time fee, paid in the developer dashboard along with a verified contact email. Check the dashboard for the current amount before you register.
Most reviews finish within a few days, but some take a few weeks. New developers, new extensions, broad host permissions and large changes all add time, and every update you submit is reviewed again. Asking for only the sites you need is the simplest way to keep reviews short.
Yes. For yourself, keep it unpacked and skip the store. On the Chrome Web Store, an unlisted extension can be installed by anyone with its link but doesn't appear in search, and a private one is limited to the testers or the organization you choose. Both still go through review.
Often, with changes. Most desktop browsers built on the same extension standard load a Manifest V3 extension, and Chrome now supports the standard browser.* namespace as well as chrome.*. Each browser has its own store, its own review and a few APIs of its own, so test it there and check that browser's documentation before you publish.
And if a small extension is exactly what you need, the steps above are all of it. Build it, keep it narrow, and keep it for yourself or publish it.