Build a Browser Extension

On this page
  1. ⚠️ Check the Manifest First
  2. 🧭 What an Extension Can and Cannot Do
  3. πŸ“ 1. What an Extension Actually Is
  4. ✍️ 2. The Content Script
  5. πŸ”Œ 3. Load It Into Chrome
  6. πŸ› 4. Debugging a Content Script
  7. πŸŽ›οΈ 5. Add a Popup and Save Settings
  8. 🌍 6. Running on More Than One Site
  9. πŸ“Έ 7. How to Show It to People
  10. πŸ”Œ Call Your Own Function
  11. πŸ”‘ Or Let Each User Bring a Key
  12. βœ… Quick Checklist
  13. πŸ“š Going Deeper

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.

Mini Project 4 is a browser extension, and this guide has the steps. The JavaScript you learned for your own pages works just as well on pages you did not write.

Add only what you can explain. If someone asks "what does this file do," you should have an answer.

⚠️ Check the Manifest First#

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.

Your agent will usually write V3. Most older extension tutorials are V2, so open manifest.json and check before you trust the rest.

If you see any of these, you have V2 code:

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 see one, 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.

🧭 What an Extension Can and Cannot Do#

It can read and change any page you name, add a popup, a side panel, a right-click menu or a keyboard shortcut, remember things with chrome.storage, and call your own function. It can read everything on the pages it matches, including what you type there, so name only the pages it needs.

It cannot run on chrome:// pages or the Chrome Web Store, see the page's JavaScript variables unless you run the script in the page's own world (world: "MAIN"), keep running in the background (Chrome stops its background script after 30 seconds with nothing to do), load code from elsewhere, or run on a phone.

Build a web page instead when your tool stands on its own, or when people need it on a phone. Build an extension when it has to read or change a page you do not own, or sit beside whatever page you are on.

πŸ“ 1. What an Extension Actually Is#

The example in this guide is a Buzzword Meter: on a job posting, it counts words such as thrive, fast-paced and cross-functional, and shows the score at the top of the page.

Make a folder. Put these files in it:

buzzword-meter/
β”œβ”€β”€ 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": "Buzzword Meter",
  "description": "Shows how many buzzwords a job posting uses.",
  "version": "1.0",
  "content_scripts": [
    {
      "matches": ["https://jobs.lever.co/*"],
      "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://jobs.lever.co/* means every page on jobs.lever.co, where many companies post their jobs. 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 buzzwords on the page and puts a banner at the top:

const BUZZWORDS = [
  'thrive', 'impact', 'cross-functional', 'fast-paced',
  'leverage', 'end-to-end', 'ownership', 'passionate',
  'ambiguity', 'innovative', 'world-class', 'best-in-class',
  'self-starter', 'north star', 'seamless', 'synergy'
];

function countBuzzwords(words) {
  const text = document.body.innerText.toLowerCase();
  let total = 0;
  for (const word of words) {
    total += text.split(word).length - 1;
  }
  return total;
}

function showBanner(score) {
  const banner = document.createElement('div');
  banner.id = 'buzzword-banner';
  banner.textContent = `Buzzword score: ${score}`;
  document.body.prepend(banner);
}

showBanner(countBuzzwords(BUZZWORDS));

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

text.split(word) cuts the page's text wherever the word appears, so the number of pieces is one more than the number of times it appears. It matches the letters wherever they appear, inside longer words too: impact also matches impactful, and leverage does not match leveraging. Which words go on the list, and in which form, is your decision.

content.css styles the banner:

#buzzword-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 buzzword-meter folder
  4. Open a job posting at jobs.lever.co/spotify

The score should be at the top of the page. Then open the same jobs on Spotify's own site, lifeatspotify.com/jobs. There should be no banner, because matches names only jobs.lever.co.

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.

If the banner appears and then disappears, the page has redrawn itself after your script ran, and the redraw removed what you added. Some job boards build their pages this way. Ask your agent to add the banner again when the page changes, or start on a site that does not redraw.

πŸ€– 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 jobs.lever.co" gets a targeted answer. A bare error message usually gets a guess.

πŸŽ›οΈ 5. Add a Popup and Save Settings#

Right now the word list is fixed and the banner is always on. A popup fixes both: the user can turn it off and add words of their own.

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": "Buzzword Meter",
  "description": "Shows how many buzzwords a job posting uses.",
  "version": "1.0",
  "permissions": ["storage"],
  "action": {
    "default_popup": "popup.html"
  },
  "content_scripts": [
    {
      "matches": ["https://jobs.lever.co/*"],
      "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: 240px; font: 14px system-ui, sans-serif; padding: 12px; }
      label { display: block; margin-bottom: 10px; }
      #status { color: #16a34a; min-height: 1em; }
    </style>
  </head>
  <body>
    <label>
      <input type="checkbox" id="enabled"> Show the score
    </label>
    <label>
      Add a word
      <input type="text" id="word">
    </label>
    <button id="add">Add</button>
    <p>Your words: <span id="my-words"></span></p>
    <p id="status"></p>
    <script src="popup.js"></script>
  </body>
</html>

popup.js reads and writes the settings:

const enabledBox = document.getElementById('enabled');
const wordInput = document.getElementById('word');
const addButton = document.getElementById('add');
const myList = document.getElementById('my-words');
const status = document.getElementById('status');

async function load() {
  const settings = await chrome.storage.sync.get({ enabled: true, myWords: [] });
  enabledBox.checked = settings.enabled;
  myList.textContent = settings.myWords.join(', ');
}

async function saveEnabled() {
  await chrome.storage.sync.set({ enabled: enabledBox.checked });
  status.textContent = 'Saved. Refresh the page.';
}

async function addWord() {
  const word = wordInput.value.trim().toLowerCase();
  // An empty word would split the page between every character.
  if (word === '') {
    status.textContent = 'Type a word first.';
    return;
  }
  const settings = await chrome.storage.sync.get({ myWords: [] });
  // The same word twice would count every match twice.
  if (settings.myWords.includes(word)) {
    status.textContent = 'That word is already on your list.';
    return;
  }
  const myWords = settings.myWords.concat(word);
  await chrome.storage.sync.set({ myWords: myWords });
  myList.textContent = myWords.join(', ');
  wordInput.value = '';
  status.textContent = 'Added. Refresh the page.';
}

enabledBox.addEventListener('change', saveEnabled);
addButton.addEventListener('click', addWord);
load();

chrome.storage.sync.get() takes an object of defaults, so { enabled: true, myWords: [] } means "give me these keys, and use these values if they were never saved." It returns a promise, which is why load, saveEnabled and addWord 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. Keep BUZZWORDS, countBuzzwords() and showBanner(), and replace the last line of content.js, showBanner(countBuzzwords(BUZZWORDS));, with this. If you keep both, the old line still shows a banner whatever the settings say:

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

  // A word the user added that is already in BUZZWORDS would be counted twice.
  const extra = settings.myWords.filter(word => !BUZZWORDS.includes(word));
  showBanner(countBuzzwords(BUZZWORDS.concat(extra)));
}

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://jobs.lever.co/* is a safe place to start. A buzzword meter for every job site is tempting, and when you widen matches, 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. Adding one job board at a time, each with its own address, is usually enough. 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 is not hosted anywhere. It lives in a folder and runs inside the browser of whoever loads it. The Chrome Web Store exists, but it charges a registration fee and reviews submissions, and you do not need it to share one.

What works instead:

  • Put the folder in a public GitHub repo, with manifest.json in the root folder
  • Write the install steps in the README: Code, then Download ZIP, unzip it, open chrome://extensions, turn on Developer mode, click Load unpacked, and pick the folder that holds manifest.json
  • Add an index.html that says what the extension does and repeats those steps, and deploy it, so the repo has a live link
  • A short GIF of the extension working on a real site, at the top of the README, shows a reader what it does before they install it

For Mini Project 4, the Submission section of the spec on Canvas lists what the repo has to contain.

πŸ”Œ Call Your Own Function#

An extension can use a model through a function you have on Vercel, as Deploy on Vercel When Your API Needs a Key describes. Three rules apply:

  • The key stays in Vercel's settings. Anyone who installs your extension can read every file in it.

  • Your address goes in manifest.json. Without it, Chrome blocks the call with a CORS error:

    "host_permissions": ["https://<project-name>.vercel.app/*"]
  • Scripts stay in the folder. Chrome refuses a script that an extension page loads from a CDN, so a library has to be a file in your extension.

πŸ”‘ Or Let Each User Bring a Key#

With BYOK (bring your own key), each person who installs your extension types their own OpenRouter key into its popup, and the extension calls OpenRouter itself. You need no server, and the user pays for their own calls.

  • Save the key with chrome.storage.local. It stays in that user's browser, and web pages cannot read it. chrome.storage.sync would copy it to every computer where they sign in to Chrome.

  • Add OpenRouter to manifest.json:

    "host_permissions": ["https://openrouter.ai/*"]
  • Call the Responses API the way Calling an LLM API does, with the user's key in the header:

    async function askAI(prompt) {
      const { apiKey } = await chrome.storage.local.get('apiKey');
      if (!apiKey) {
        throw new Error('Paste your OpenRouter key in the popup first.');
      }
      const response = await fetch('https://openrouter.ai/api/v1/responses', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${apiKey}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          model: 'deepseek/deepseek-v4.1-flash-1017',
          input: prompt
        })
      });
      if (!response.ok) {
        throw new Error(`Request failed: ${response.status}`);
      }
      const data = await response.json();
      const message = data.output.find(item => item.type === 'message');
      return message.content[0].text;
    }
  • Ask users to set a credit limit on their key. Never put your own key or the course key in the folder, and test with a key of your own.

βœ… Quick Checklist#

  • manifest.json says "manifest_version": 3
  • None of the five 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 README has the install steps, and index.html is deployed

πŸ“š 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 or react to tabs opening, 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: Thursday, 11/12/2026