A Chrome extension is a small web application that lives inside your browser. It can read and modify the content of any web page you visit, add buttons to Chrome's toolbar, intercept network requests, save data locally, and interact with dozens of browser APIs.
Extensions built by other people run inside your browser every day: ad blockers filter network requests before they load, grammar checkers read your text as you type, password managers autofill your credentials, and dark mode extensions override every page's stylesheet. All of these are Chrome extensions.
The surprising part: if you know basic HTML and JavaScript, you can build one today.
What extensions can actually do
Before building, it is worth being concrete about the range of things Chrome extensions can do:
- Modify any web page — change fonts, colors, layouts, or add entirely new UI elements to any site
- Block ads and trackers — intercept network requests before they load
- Add keyboard shortcuts — globally in Chrome or on specific pages
- Save and sync data — store notes, bookmarks, or settings in Chrome's built-in storage, synced across your devices
- Automate forms — prefill fields, click buttons, or submit forms programmatically
- Show notifications — trigger desktop notifications from the browser
- Add a sidebar or panel — show custom UI alongside any web page
- Intercept and modify requests — add headers, redirect URLs, or block specific resources
- Read page content — extract text, links, prices, or any data from a page
The ExplainX platform itself ships a Chrome extension that overlays on web pages to surface relevant skills and agent templates while you're browsing. That is a practical example of what a content-script-based extension looks like in production.
The three files every extension needs
Every Chrome extension, regardless of complexity, is built from the same three-file structure:
1. manifest.json — the control file
This is the only required file. It tells Chrome everything it needs to know about your extension: its name, version, what permissions it needs, which scripts to load, and when to load them. No manifest, no extension.
2. content.js — the page script
A content script runs inside the web pages your extension is active on. It has direct access to the page's DOM — the HTML elements you see in the browser. It can read text, change colors, add buttons, listen for user events, and do anything JavaScript can do on a webpage.
One important restriction: content scripts cannot use the Chrome extension APIs directly (except chrome.runtime for messaging). They also cannot access variables from the page's own JavaScript, and the page's JavaScript cannot access the content script's variables. They are sandboxed from each other.
3. popup.html + popup.js — the extension UI
When you click the extension icon in Chrome's toolbar, a small window appears. That window is a normal HTML page — popup.html. It has its own JavaScript file, popup.js, which runs in that popup context and has full access to Chrome extension APIs.
The popup is a great place to put controls, settings, or status information. It is not necessary for every extension — some extensions work entirely through content scripts with no popup at all.
Building a real extension: Link Highlighter
Let us build a complete, working extension. When you click the extension icon, it shows a button that says "Highlight all links." Clicking the button turns the background of every <a> tag on the current page yellow so you can see every link at a glance.
Create a new folder anywhere on your computer. Call it link-highlighter. All three files go inside this folder.
File 1: manifest.json
{
"manifest_version": 3,
"name": "Link Highlighter",
"version": "1.0",
"description": "Highlights all links on any page so you can see them at a glance.",
"action": {
"default_popup": "popup.html",
"default_title": "Link Highlighter"
},
"permissions": ["activeTab", "scripting"]
}
What each field does:
manifest_version: 3— required. Tells Chrome this uses the current MV3 format.name— displayed in Chrome's extensions page and the Web Store.version— a string inmajor.minor.patchformat. Must increase each time you submit an update.description— shown under the extension name in the Web Store.action.default_popup— which HTML file to show when the user clicks the extension icon.action.default_title— the tooltip shown on hover over the extension icon.permissions— the list of Chrome APIs your extension is allowed to use:activeTab— access to the currently active tab (its URL, DOM) while the user is interacting with the extensionscripting— permission to inject JavaScript into tabs usingchrome.scripting.executeScript
No other permissions are needed. This extension cannot read your history, access other tabs, or run in the background.
File 2: popup.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
body {
width: 220px;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
}
h1 {
font-size: 16px;
margin: 0 0 12px 0;
}
button {
width: 100%;
padding: 10px;
background: #2563eb;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
button:hover {
background: #1d4ed8;
}
#status {
margin-top: 10px;
color: #16a34a;
font-size: 13px;
min-height: 18px;
}
</style>
</head>
<body>
<h1>Link Highlighter</h1>
<button id="highlightBtn">Highlight all links</button>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>
This is a normal HTML file. The only Chrome-specific thing about it is that it will be rendered inside the popup window. Keep popups narrow (180–350px is standard) because they float over the browser window.
The <script src="popup.js"> at the bottom loads the logic. Always load scripts at the bottom of the body, not in the <head> — this ensures the DOM elements exist before the script tries to reference them.
File 3: popup.js
document.getElementById("highlightBtn").addEventListener("click", async () => {
const statusEl = document.getElementById("status");
// Get the currently active tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
// Inject a function into that tab's page
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: highlightLinks,
});
// Read how many links were highlighted from the result
const count = results[0].result;
statusEl.textContent = `Highlighted ${count} link${count !== 1 ? "s" : ""}`;
});
// This function runs INSIDE the web page, not in the popup
// It has access to the page's DOM
function highlightLinks() {
const links = document.querySelectorAll("a");
links.forEach((link) => {
link.style.backgroundColor = "yellow";
link.style.color = "#000";
link.style.padding = "1px 2px";
link.style.borderRadius = "2px";
});
return links.length; // This value is returned to popup.js
}
Three things to understand here:
chrome.tabs.query finds the active tab. It is async and returns an array; we destructure the first (and only) item with const [tab].
chrome.scripting.executeScript injects a function into the tab's page. The func property takes a function reference — highlightLinks — and runs it in the page's context. This is how the popup "reaches into" the actual web page.
func runs in page context, not popup context. This is the most common source of confusion for beginners. The highlightLinks function cannot use any variables from popup.js — when it runs, it is executing inside the web page, not inside the extension. That is also why it returns links.length: the return value is serialized and passed back to popup.js through results[0].result.
Loading the extension into Chrome
You do not need to publish to the Chrome Web Store to test your extension. Chrome has a developer mode that lets you load any folder as an extension:
- Open Chrome and navigate to
chrome://extensionsin the address bar. - Toggle the Developer mode switch in the top-right corner of the page. New buttons will appear.
- Click Load unpacked.
- In the file picker, navigate to and select your
link-highlighterfolder (the folder itself, not a zip file). - Your extension appears in the list with its name and a green toggle indicating it is active.
- Find the extension icon in Chrome's toolbar (you may need to click the puzzle piece icon and pin Link Highlighter).
Now open any webpage with links — a Wikipedia article, a news site, anything. Click the Link Highlighter icon. Click "Highlight all links." Every link on the page turns yellow.
How to iterate and test
The edit-refresh cycle for Chrome extensions is faster than most web development:
- Edit any of your extension files and save.
- Go back to
chrome://extensions. - Find your extension and click the circular refresh icon (or press
Ctrl+R/Cmd+Rwhile focused on the extensions page). - Reload the webpage you were testing on.
- Test the change.
You do not need to reload Chrome or restart anything. The refresh icon forces Chrome to re-read your extension folder from disk.
If your popup is open when you make a change, close and reopen it — the popup does not hot-reload.
To debug popup scripts, right-click anywhere inside the open popup and choose Inspect. This opens Chrome DevTools scoped to the popup context. You can see console.log output, set breakpoints, and inspect the popup's DOM.
To debug content scripts, open DevTools on the regular page (F12 or Cmd+Option+I) and look in the Sources tab under Content scripts.
What Manifest V3 means and why it matters
If you search for older Chrome extension tutorials, you will find Manifest V2 code that no longer works. Google phased out MV2 support in 2024. All new extensions must use MV3.
The key differences that affect most developers:
| Feature | Manifest V2 | Manifest V3 |
|---|---|---|
| Background scripts | Persistent background page | Service workers (terminate when idle) |
| Network interception | webRequest API (can block/modify) | declarativeNetRequest (rule-based, no code logic) |
| Code injection | executeScript with string of code | executeScript with function reference or separate file |
| Remote code | Allowed | Blocked entirely |
For the extension you just built, none of these differences affect you — the Link Highlighter is MV3-native. But if you find yourself copying older tutorials, watch for background pages (replace with service workers) and chrome.webRequest (replace with declarativeNetRequest rules).
Service workers in MV3 terminate after ~30 seconds of inactivity. If your extension needs to run background logic continuously, you need to design around this — for example, using chrome.alarms to periodically wake the service worker.
Common permissions and what they do
Permissions must be declared in manifest.json and are shown to users during installation. Only request what you actually need.
| Permission | What it grants |
|---|---|
activeTab | Access to the currently active tab when the user triggers the extension |
scripting | Ability to inject scripts into tabs |
storage | Read/write to chrome.storage.local and chrome.storage.sync |
tabs | Access to tab metadata (URLs, titles) for all tabs — broader than activeTab |
webRequest | (MV2 only) Intercept and inspect network requests |
notifications | Show desktop notifications |
cookies | Read and write cookies for specified domains |
contextMenus | Add items to Chrome's right-click context menu |
alarms | Schedule recurring callbacks (wake a service worker on a timer) |
history | Read the user's browsing history |
bookmarks | Read and write Chrome bookmarks |
Host permissions are separate from API permissions. If you want your content script to run on all pages, add "host_permissions": ["<all_urls>"] to your manifest. If you only need it on one domain, use "host_permissions": ["https://example.com/*"]. Narrower host permissions result in easier approval from the Web Store and less user concern during install.
How to add a keyboard shortcut
Keyboard shortcuts are one of the most useful things to add to an extension. Users can trigger your extension without clicking the icon.
In manifest.json, add a commands block:
{
"manifest_version": 3,
"name": "Link Highlighter",
"version": "1.1",
"description": "Highlights all links on any page.",
"action": {
"default_popup": "popup.html",
"default_title": "Link Highlighter"
},
"permissions": ["activeTab", "scripting"],
"commands": {
"highlight-links": {
"suggested_key": {
"default": "Ctrl+Shift+H",
"mac": "Command+Shift+H"
},
"description": "Highlight all links on the current page"
}
}
}
Then in a service worker file (add "background": { "service_worker": "background.js" } to your manifest), listen for the command:
// background.js
chrome.commands.onCommand.addListener(async (command) => {
if (command === "highlight-links") {
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
document
.querySelectorAll("a")
.forEach((a) => (a.style.backgroundColor = "yellow"));
},
});
}
});
Users can override the suggested shortcut in chrome://extensions/shortcuts.
Publishing to the Chrome Web Store
When you're ready to share your extension with others:
1. Create a developer account. Go to the Chrome Web Store Developer Dashboard. Sign in with a Google account and pay the one-time $5 registration fee.
2. Package your extension. On chrome://extensions, click Pack extension and select your folder, or simply zip the folder's contents (not the folder itself — the zip should contain manifest.json at its root).
3. Create a new item. In the Developer Dashboard, click New Item and upload the zip file.
4. Fill in the Store listing. You'll need: a description (at least 132 characters), at least one screenshot (1280×800 or 640×400), and optionally a promotional banner image.
5. Submit for review. Chrome Web Store reviews new extensions manually. First-time reviews typically take 1–3 business days. Updates to existing extensions are usually faster.
6. Privacy disclosures. If your extension collects any user data, you must disclose it in the store listing and in a privacy policy URL. Extensions that collect data without disclosure are rejected.
After approval, your extension is live and searchable. Anyone with Chrome can install it.
Beyond the basics: what to build next
Once the Link Highlighter is working, here are natural extensions of the pattern:
Save selected text to a note. Add a context menu item (right-click on selected text → "Save to notes"). Store the text in chrome.storage.local. Add a popup view that shows all saved notes.
Custom CSS injector. Let the user type CSS in the popup. Inject it into the current page with chrome.scripting.insertCSS. Store the CSS per-domain in chrome.storage.sync so it applies automatically on repeat visits.
Word count overlay. Use a content script that counts words on a page and displays the count in a floating badge in the corner of every article you read.
Tab group manager. Use the tabs and tabGroups APIs to auto-group open tabs by domain, keeping your work tabs separate from your reading tabs.
For all of these, the structure is the same as what you just built: manifest.json declares what the extension is, content scripts touch the page, popup scripts handle the UI, and the Chrome APIs bridge between them.
For all of these, you'll also want to track your work with version control so you can roll back bad changes easily. If you haven't set that up yet, the Git and GitHub beginner guide takes you from installation to your first pushed commit.
If you want to explore AI-powered extensions and tools built on top of the browser, browse the ExplainX skills registry — many of the skills there are designed to run in browser contexts, and seeing how they are structured is a good reference for your own builds.
For the foundational web development skills that underpin Chrome extension development — especially JavaScript and the DOM — the ExplainX Claude for Work workshop includes modules on using AI to accelerate your own learning when building browser tools. If you need Node.js for your build tools or for running a local dev server alongside your extension, the Node.js beginner guide covers installation and the basics.
Quick-reference: the complete Link Highlighter
Here is the entire extension in one place so you can copy it without scrolling:
manifest.json
{
"manifest_version": 3,
"name": "Link Highlighter",
"version": "1.0",
"description": "Highlights all links on any page so you can see them at a glance.",
"action": {
"default_popup": "popup.html",
"default_title": "Link Highlighter"
},
"permissions": ["activeTab", "scripting"]
}
popup.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
body { width: 220px; padding: 16px; font-family: -apple-system, sans-serif; font-size: 14px; }
h1 { font-size: 16px; margin: 0 0 12px 0; }
button { width: 100%; padding: 10px; background: #2563eb; color: white; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
button:hover { background: #1d4ed8; }
#status { margin-top: 10px; color: #16a34a; font-size: 13px; }
</style>
</head>
<body>
<h1>Link Highlighter</h1>
<button id="highlightBtn">Highlight all links</button>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>
popup.js
document.getElementById('highlightBtn').addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const links = document.querySelectorAll('a');
links.forEach(link => {
link.style.backgroundColor = 'yellow';
link.style.color = '#000';
});
return links.length;
}
});
const count = results[0].result;
document.getElementById('status').textContent = `Highlighted ${count} link${count !== 1 ? 's' : ''}`;
});
Three files, one folder, ten minutes from start to a working extension in Chrome.