How to build a Chrome extension from scratch with Manifest V3
A Chrome extension isn’t a regular website
A Chrome extension is a small application that lives inside the browser. It can add a popup to the toolbar, modify pages, listen for browser events, or store local data.
The difference from a regular website is permissions. An extension can read the active tab, store settings, or interact with a URL. Chrome doesn’t hand out that access for free. You have to declare it up front in a file called manifest.json.
Over the past few days I’ve been working on my own extension. I can’t show it in full yet because I want to nail down a few flows before sharing it, but it’s brought me back to a pretty clear conclusion: building a small extension is easier than it looks. The hard part starts when you want it to be something more than an anxious drawer full of buttons.
To explain the basics I’m going to build a working extension called Read Later Tabs. It will have a popup, read the current tab, and let you save it to a local list to review later.
No backend needed. No React needed. No need to install half a galaxy of dependencies. To get started, I prefer it that way.
The minimal project structure
I create a folder with these files:
read-later-tabs/
├── manifest.json
├── popup.html
├── popup.css
└── popup.js
Chrome loads extensions from a local folder during development. There’s no mandatory build step if I use plain HTML, CSS, and JavaScript.
My recommendation for a first extension is this: start without a framework. If the product grows, there’ll be time to bring in Vite, TypeScript, React, or whatever fits. Adding complexity before you have a useful feature is a web tradition I try to avoid.
The manifest.json: the contract with Chrome
The manifest is the main file. It defines the extension’s name, its version, the permissions, and the files Chrome should load.
Since 2023, the right choice is Manifest V3. Manifest V2 has been retired in Chrome, and following old tutorials tends to end in fairly boring errors.
I create this file:
{
"manifest_version": 3,
"name": "Read Later Tabs",
"version": "1.0.0",
"description": "Save the current tab to read later.",
"permissions": ["storage", "tabs"],
"action": {
"default_title": "Save for later",
"default_popup": "popup.html"
}
}
Each field has a purpose:
| Field | What it’s for |
|---|---|
manifest_version | Indicates the version of the extensions system. Must be 3. |
name | Name shown in Chrome. |
version | The extension’s version. Chrome uses it when updating. |
permissions | APIs I want to access. |
action | Configures the toolbar icon and its popup. |
I use two permissions:
storagelets me save data withchrome.storage.tabslets me read the title and URL of the active tab.
Permissions matter more than they seem to. Chrome can show them when installing an extension, and users do review them. Rightly so. If my extension only saves tabs, asking for access to every website in the browser would be a bad sign.
I prefer to apply a simple rule: I ask for the minimum permission each feature needs.
Building the extension popup
The popup is the small window that appears when you click an extension’s icon in Chrome’s toolbar.
It isn’t a special page. It’s regular HTML, with its limits. It has a small size and closes when the user clicks outside it. That constrains the design quite a bit.
I create popup.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Read Later Tabs</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<main class="popup">
<header>
<h1>Read later</h1>
<p>Save the active tab to review later.</p>
</header>
<button id="save-tab" type="button">Save current tab</button>
<section aria-labelledby="saved-tabs-title">
<h2 id="saved-tabs-title">Saved</h2>
<ul id="saved-tabs" class="tabs-list"></ul>
</section>
</main>
<script src="popup.js"></script>
</body>
</html>
There’s an important detail: I don’t use inline JavaScript.
This wouldn’t work properly in a modern extension:
<button onclick="saveCurrentTab()">Save</button>
Chrome enforces a strict security policy called Content Security Policy. Among other things, it blocks inline scripts. The solution is to load an external JavaScript file, as I do with popup.js.
I also avoid loading CSS or scripts from a CDN. Chrome extensions must bundle their resources locally. If I need a library, I include it inside the project.
Giving it a readable style
An extension doesn’t need to win a design award to be useful, but it also doesn’t have to look like a 2007 settings window.
I create popup.css:
:root {
font-family: Inter, system-ui, sans-serif;
color: #e5e7eb;
background: #111827;
}
body {
width: 340px;
margin: 0;
}
.popup {
padding: 16px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 4px;
font-size: 18px;
}
h2 {
margin: 20px 0 10px;
font-size: 14px;
}
p {
color: #9ca3af;
font-size: 13px;
line-height: 1.5;
}
Then I add the styles for the button and the list:
button {
width: 100%;
border: 0;
border-radius: 8px;
padding: 10px 12px;
color: #111827;
background: #fbbf24;
cursor: pointer;
font-weight: 700;
}
button:hover {
background: #fcd34d;
}
.tabs-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.tab-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
border-radius: 8px;
background: #1f2937;
}
The popup has a fixed width because Chrome computes its size from the content. If I don’t cap the width, I can end up with an awkward interface, or with long titles breaking the layout.
I finish the CSS with the link and the button for removing items:
.tab-link {
flex: 1;
overflow: hidden;
color: #e5e7eb;
font-size: 13px;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-tab {
width: auto;
padding: 4px 8px;
color: #fca5a5;
background: transparent;
font-size: 12px;
}
.remove-tab:hover {
background: #374151;
}
.empty-state {
color: #9ca3af;
font-size: 13px;
}
Reading the active tab with the Chrome API
Now comes the part that turns an HTML page into an extension.
Chrome exposes global APIs through the chrome object. Here I use chrome.tabs to get the active tab and chrome.storage.local to store data inside the browser.
I start popup.js by selecting the DOM elements:
const saveButton = document.querySelector("#save-tab");
const tabsList = document.querySelector("#saved-tabs");
saveButton.addEventListener("click", saveCurrentTab);
document.addEventListener("DOMContentLoaded", renderSavedTabs);
When the user clicks the button, I run saveCurrentTab. This function queries the tab visible in the current window:
async function getCurrentTab() {
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
return tab;
}
chrome.tabs.query() returns an array. Even though I only ask for one active tab, the API keeps that format. That’s why I extract the first element with [tab].
Now I save the data I care about:
async function saveCurrentTab() {
const tab = await getCurrentTab();
if (!tab.url || !tab.title) {
return;
}
const savedTabs = await getSavedTabs();
const alreadySaved = savedTabs.some((savedTab) => savedTab.url === tab.url);
if (alreadySaved) {
return;
}
const nextTabs = [{ title: tab.title, url: tab.url }, ...savedTabs];
await chrome.storage.local.set({ savedTabs: nextTabs });
renderSavedTabs();
}
I don’t store the full object Chrome returns. It has properties I don’t need, and some can change between browser versions.
I only store title and url. It’s a small, predictable object, easy to migrate if I change the format later.
I also avoid duplicates by comparing the URL. If I save the same tab five times, the extension stops being a reading list and turns into a list of the times I wasn’t paying attention.
Storing data with chrome.storage.local
chrome.storage.local works like key-value storage. It resembles localStorage, but it’s designed for extensions and works properly across their different parts.
I create a function to retrieve the saved list:
async function getSavedTabs() {
const data = await chrome.storage.local.get("savedTabs");
return data.savedTabs ?? [];
}
I use ?? [] because the first time around there won’t be any key called savedTabs. Without that fallback, my code would try to iterate over undefined and the popup would die quietly. JavaScript errors have a talent for that.
There’s another storage called chrome.storage.sync. That one syncs data across Chrome browsers where the user is signed in.
For a small list I could use it. Even so, I prefer local when starting out. It has looser quota limits and doesn’t turn a local test into a sync problem.
Rendering the saved tabs
The extension can already save information, but it still doesn’t show anything when the popup opens. For that I create renderSavedTabs.
async function renderSavedTabs() {
const savedTabs = await getSavedTabs();
tabsList.innerHTML = "";
if (savedTabs.length === 0) {
tabsList.innerHTML =
'<li class="empty-state">You haven\'t saved any tabs yet.</li>';
return;
}
savedTabs.forEach((tab) => {
const item = createTabItem(tab);
tabsList.append(item);
});
}
If there are no items, I show an empty state. It’s a small detail, but it keeps the interface from looking broken.
To create each list item I use the DOM instead of interpolating HTML with external URLs:
function createTabItem(tab) {
const item = document.createElement("li");
const link = document.createElement("a");
const removeButton = document.createElement("button");
item.className = "tab-item";
link.className = "tab-link";
link.href = tab.url;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = tab.title;
removeButton.className = "remove-tab";
removeButton.type = "button";
removeButton.textContent = "Remove";
removeButton.addEventListener("click", () => removeTab(tab.url));
item.append(link, removeButton);
return item;
}
I use textContent to insert the title. I don’t use innerHTML with data coming from an external tab.
A website’s title can contain unexpected characters or even HTML. textContent prevents that content from being interpreted as code. Even for a small, local extension, I don’t like normalizing unsafe habits.
I finish with the function for removing tabs:
async function removeTab(url) {
const savedTabs = await getSavedTabs();
const nextTabs = savedTabs.filter((tab) => tab.url !== url);
await chrome.storage.local.set({ savedTabs: nextTabs });
renderSavedTabs();
}
With this I now have the full cycle:
- Chrome opens the popup.
- The popup loads the saved tabs.
- I click the button and read the active tab.
- I save its title and URL.
- I update the interface.
- I can open or remove any item.
Loading the extension into Chrome
To test it I open this URL in Chrome:
chrome://extensions
Then I follow these steps:
- I turn on the Developer mode switch.
- I click Load unpacked.
- I select the
read-later-tabsfolder. - I pin the extension to the toolbar using the puzzle piece icon.
- I open a website and click the extension icon.
Chrome will show the extension with a generic icon because I haven’t added my own icons. It doesn’t affect functionality.
Every time I change manifest.json, I go back to chrome://extensions and click the reload button. For changes to HTML, CSS, or JavaScript, I do it out of habit too. Then I close and reopen the popup.
The popup doesn’t refresh on its own while it’s open. That’s normal. It isn’t a mysterious bug or proof that Chrome hates me. Well, not this time.
When I need a service worker
This extension doesn’t use a service worker because everything happens while the popup is open.
I need one when I want to run code without opening the popup. For example:
- Listen for installs or updates.
- Create context menus.
- Respond to navigation events.
- Run background tasks.
- Coordinate messages between a popup and scripts injected into pages.
In Manifest V3, the old background page is replaced by a service worker. Chrome starts it when needed and stops it when it goes idle.
A minimal configuration would look like this:
{
"background": {
"service_worker": "background.js"
}
}
And the background.js file could listen for the installation:
chrome.runtime.onInstalled.addListener(() => {
console.log("Read Later Tabs installed");
});
I don’t add this to the main example because it doesn’t add any real functionality yet. Throwing in a service worker “just in case” is like installing Kubernetes for a landing page. Technically possible. Reasonable, not so much.
Common mistakes when building your first extension
The most common mistake is using Manifest V2 tutorials. If I see browser_action, background.page, or manifest_version: 2, I close that tab and look for another reference.
I also see extensions asking for excessive permissions. "<all_urls>" allows acting on any page. I only use it when I need to inject code into arbitrary sites. For a popup that reads the current tab, it isn’t necessary.
Another frequent mistake is relying on localStorage. It can work in certain contexts, but chrome.storage is the API designed for extensions. It has async APIs, can be shared across the extension’s components, and avoids confusing behavior.
Finally, it’s not a good idea to store secrets inside the extension. An API key embedded in JavaScript can be inspected. If my extension needs to access a private service, I prefer an intermediary backend or user-token-based authentication.
The next step is building something worth keeping installed
The foundation of a Chrome extension fits in four files: a manifest, a UI, styles, and JavaScript. From there, the browser APIs do the interesting work.
My own extension has already moved past that initial phase, and I’ll share more details soon. The hard part isn’t building the popup. The hard part is deciding which problem deserves permanent space in the browser.