Build a Browser Extension

Every website you visit is HTML, CSS, and JavaScript that someone else wrote. A browser extension lets you add your own code to that page. The real page, in your real browser, with your JavaScript running inside it.

You already know how to write that code. A Chrome extension is a folder with a manifest.json file in it. There is no build step, no server, and no deployment. You point Chrome at the folder and it runs.

None of this is required for the course. It is a good weekend project, and it shows you that the JavaScript you learned for your own pages works just as well on pages you did not write.

One rule before you start: only add what you can explain. If someone asks "what does this file do," you should have an answer.

⚠️ Read This First: AI Will Give You Dead Code

This is the one guide in the course where you should expect AI to fail on the first try.

Chrome extensions have two generations. Manifest V2 is the old one, and Manifest V3 is the current one. Chrome disabled every V2 extension in July 2025, and users cannot turn them back on. V2 is not deprecated or discouraged. It does not run.

The problem is that most extension tutorials ever written were written for V2. That is what AI learned from. So when you ask for an extension, there is a good chance you get well formatted, carefully explained code that Chrome refuses to load.

If AI hands you any of these, it gave you the dead generation:

What you see What it means What it should be
"manifest_version": 2 The whole file is V2 "manifest_version": 3
"browser_action" or "page_action" V2 toolbar button "action"
"background": { "scripts": [...] } V2 background page "background": { "service_worker": "..." }
chrome.tabs.executeScript(...) V2 injection API chrome.scripting.executeScript(...)
A URL like "https://*/*" inside "permissions" V2 permission style Move it to "host_permissions"

When you hit this, the fix is a specific prompt: "This is Manifest V2 and Chrome no longer runs it. Rewrite it for Manifest V3." Vague follow-ups like "it does not work" tend to produce more V2.

📁 1. What an Extension Actually Is

Make a folder. Put these files in it:

reading-time/
├── manifest.json
├── content.js
└── content.css

manifest.json is the only file that is new to you. It tells Chrome the extension's name, and which of your scripts to run on which pages:

{
  "manifest_version": 3,
  "name": "Reading Time",
  "description": "Shows an estimated reading time at the top of an article.",
  "version": "1.0",
  "content_scripts": [
    {
      "matches": ["https://en.wikipedia.org/*"],
      "js": ["content.js"],
      "css": ["content.css"]
    }
  ]
}

The three keys Chrome requires are manifest_version, name, and version. Everything else describes what your extension does.

Inside content_scripts:

  • matches is the list of pages your code runs on. https://en.wikipedia.org/* means every page on Wikipedia. This is the field that decides how much of the web your extension touches, so keep it narrow while you are learning.
  • js and css are your files. Ordinary JavaScript and ordinary CSS.

A script listed under js is called a content script. That is the whole concept: your JavaScript, running on somebody else's page, with access to the same document you have been using all semester.

✍️ 2. The Content Script

content.js counts the words on the page and puts a banner at the top:

const WORDS_PER_MINUTE = 200;

function countWords() {
  const text = document.body.innerText || '';
  return text.trim().split(/\s+/).length;
}

function showBanner(minutes) {
  const banner = document.createElement('div');
  banner.id = 'reading-time-banner';
  banner.textContent = `About ${minutes} min read`;
  document.body.prepend(banner);
}

const words = countWords();
const minutes = Math.max(1, Math.round(words / WORDS_PER_MINUTE));
showBanner(minutes);

Read that again and notice what is in it: document.body, createElement, textContent, prepend. There is not a single extension-specific line. It is the DOM code from class, pointed at a page you did not write.

content.css styles the banner:

#reading-time-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 9999;
  background: #1f2937;
  color: white;
  font: 600 14px system-ui, sans-serif;
  padding: 8px 12px;
  text-align: center;
}

The high z-index matters. You are stacking your element on top of a page whose CSS you do not control, and the page may have its own positioned elements fighting for the top of the screen.

🔌 3. Load It Into Chrome

There is no deploy step. Chrome runs the folder directly:

  1. Open a new tab and go to chrome://extensions
  2. Turn on Developer mode with the toggle in the top right
  3. Click Load unpacked and select your reading-time folder
  4. Open any Wikipedia article

The banner should be at the top of the page.

When you change a file, go back to chrome://extensions and click the reload icon on your extension's card, then refresh the web page. Forgetting this step is the most common reason a fix appears to do nothing.

🐛 4. Debugging a Content Script

Content script errors do not appear where you might look for them first. They are not on the extensions page, because your script is not running in the extension. It is running inside the website.

So open DevTools on the web page itself, and look at the Console. Your console.log output is there, mixed in with whatever the site logs on its own.

If you cannot find your messages, use the context dropdown at the top left of the Console panel. It usually says top. Click it and select your extension to filter the console down to only your script's output.

A popup is different: right-click the extension's icon and inspect the popup to get its own DevTools window.

🤖 AI tip: When you paste an extension error into AI, say which file it came from and which console you found it in. "Error in content.js, seen in the page console on Wikipedia" gets a targeted answer. A bare error message usually gets a guess.

🎛️ 5. Add a Popup and Save Settings

Right now the reading speed is hardcoded and the banner is always on. A popup fixes both.

A popup is a small HTML page that opens when someone clicks your extension's icon. It is a web page. You already build these.

Add the action and storage keys to manifest.json:

{
  "manifest_version": 3,
  "name": "Reading Time",
  "description": "Shows an estimated reading time at the top of an article.",
  "version": "1.0",
  "permissions": ["storage"],
  "action": {
    "default_popup": "popup.html"
  },
  "content_scripts": [
    {
      "matches": ["https://en.wikipedia.org/*"],
      "js": ["content.js"],
      "css": ["content.css"]
    }
  ]
}

permissions: ["storage"] is what lets you use chrome.storage. Chrome shows users what an extension asked for, so every permission you add is something you are asking a person to accept.

popup.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <style>
      body { width: 220px; font: 14px system-ui, sans-serif; padding: 12px; }
      label { display: block; margin-bottom: 10px; }
      input[type="number"] { width: 70px; }
      #status { color: #16a34a; min-height: 1em; }
    </style>
  </head>
  <body>
    <label>
      <input type="checkbox" id="enabled"> Show the banner
    </label>
    <label>
      Words per minute
      <input type="number" id="wpm" min="50" max="1000" step="10">
    </label>
    <p id="status"></p>
    <script src="popup.js"></script>
  </body>
</html>

popup.js reads and writes the settings:

const enabledBox = document.getElementById('enabled');
const wpmInput = document.getElementById('wpm');
const status = document.getElementById('status');

async function load() {
  const settings = await chrome.storage.sync.get({ enabled: true, wpm: 200 });
  enabledBox.checked = settings.enabled;
  wpmInput.value = settings.wpm;
}

async function save() {
  await chrome.storage.sync.set({
    enabled: enabledBox.checked,
    wpm: Number(wpmInput.value)
  });
  status.textContent = 'Saved. Refresh the page.';
}

enabledBox.addEventListener('change', save);
wpmInput.addEventListener('change', save);
load();

chrome.storage.sync.get() takes an object of defaults, so { enabled: true, wpm: 200 } means "give me these keys, and use these values if they were never saved." It returns a promise, which is why load and save are async. This is the same await you used for fetch.

chrome.storage is one of the few extension APIs a content script can call directly, so content.js can read the same settings without any message passing:

async function main() {
  const settings = await chrome.storage.sync.get({ enabled: true, wpm: 200 });
  if (!settings.enabled) return;

  const words = countWords();
  const minutes = Math.max(1, Math.round(words / settings.wpm));
  showBanner(minutes);
}

main();

Note that chrome.storage.sync follows the user's Chrome account across their devices. If you want the setting to stay on one machine, use chrome.storage.local instead. The calls are identical.

🌍 6. Running on More Than One Site

https://en.wikipedia.org/* is a safe place to start. When you widen it, understand what you are widening.

"matches": ["https://*/*"]

That means every https page the user visits: their email, their bank, their Canvas. Your code runs on all of it. Chrome tells the user this when they install, and it is the reason a lot of extensions get uninstalled.

Widen matches when your extension genuinely needs the reach, and be able to say why. This is a judgment call about someone else's browser, and it is worth making it deliberately.

📸 7. How to Show It to People

An extension has no URL. There is nothing to link, because it is not hosted anywhere. It lives in a folder on your computer and runs inside your browser.

That changes how you share it. The Chrome Web Store exists, but it charges a registration fee and reviews submissions, and you do not need it to learn any of this.

What works instead:

  • Put the folder in a public GitHub repo
  • Record a short GIF of the extension working on a real site and put it at the top of your README
  • Write the load unpacked steps in the README so someone can run it in two minutes

The GIF is the deliverable. It is what a reader sees before deciding whether to care.

To be clear about where this fits: an extension does not replace your final project's live URL. Your final project needs a link people can open. This is a different kind of thing, and it is worth building for a different reason.

✅ Quick Checklist

  • manifest.json says "manifest_version": 3
  • None of the five dead V2 patterns from the table above appear anywhere
  • matches is as narrow as your extension can tolerate
  • Every permission in the file is one you can justify
  • You reloaded the extension and refreshed the page after your last change
  • You can explain what every line of content.js does
  • The repo has a GIF and load unpacked instructions

📚 Going Deeper

The official documentation is genuinely good, and it is the correct place to check anything AI tells you about extensions:

Once your extension needs to run code on a schedule, react to tabs opening, or talk to an API, you need a service worker, which is the background script of an MV3 extension. That is the next layer, and Migrate to a service worker is where it starts.

Last updated: Wednesday, 7/15/2026