All Tutorials
TypeScript
Chrome Extension
Vite
Manifest V3

Build a Chrome Extension with TypeScript and Vite (Manifest V3)

A code-along tutorial: build a Chrome extension with TypeScript, Vite, and Manifest V3. A working word counter and reading-time tool with a content script, service worker, popup, and chrome.storage. Every file is inline and runs when you load it unpacked.

14 min read2026-08-04

The short answer

Build a Chrome extension by writing four small pieces: a manifest.json that declares Manifest V3, a background service worker, a content script that reads the page, and a popup for the UI. Bundle them with Vite (one config, no framework) and load the dist folder unpacked at chrome://extensions with Developer mode on. The Manifest V3 gotcha to know: the old always-on background page is gone, replaced by a service worker that sleeps when idle, so you keep state in chrome.storage, not a global variable, and every capability you use has to be listed in permissions. It is free to build and load unpacked; publishing to the Chrome Web Store costs a one-time $5 developer registration. This tutorial builds a working word counter and reading-time estimator end to end, every file inline.

  • Setup time: about 15 minutes
  • Cost: $0 to build and load unpacked; one-time $5 only if you publish
  • Stack: TypeScript + Vite + Manifest V3
  • Runtime dependencies: 0 (an extension bundles no npm packages)
  • Every file is in this page: paste it into a fresh project and it runs

What you'll build

By the end of this tutorial you'll have a real Chrome extension you can pin to your toolbar. Click its icon on any web page and a small popup tells you how many words are on the page and roughly how long it takes to read. The last result is cached, so the popup shows something the instant it opens.

A browser extension is one of the best free portfolio projects you can build. Unlike a toy to-do app, it ships to a real surface people use every day, and it makes you touch the four building blocks every extension is made of: a manifest, a content script that runs on the page, a background service worker, and a popup UI. Build this one and you understand the shape of every extension you'll write after it.

Every file is in this page. Paste each one into a fresh project, follow the setup steps, and it runs. There is no repo to clone and nothing hidden. We use TypeScript for types, Vite to bundle, and zero runtime dependencies, because an extension ships no npm packages by nature: the browser is the runtime.

Why Manifest V3, and the one gotcha

Manifest V3 (MV3) is the current extension format, and it is the only one the Chrome Web Store accepts for new extensions. If you find an old tutorial using manifest_version: 2 and a persistent background page, it is out of date.

Here is the one change that trips people up. MV3 replaced the always-running background page with a service worker that sleeps when idle and wakes on an event. You cannot stash state in a global variable in the worker and expect it to still be there later, because the worker may have shut down in between. That is exactly why this extension caches its last result in chrome.storage.local instead of a variable.

The other MV3 rule: every capability you use has to be declared in permissions. We ask for three and no more. activeTab lets us read the page the user is looking at, and only when they click our icon. scripting lets us inject the counter on demand. storage lets us cache the last count. Because we use activeTab instead of a broad host permission, the install screen does not show the scary "read all your data on all websites" warning.

What you need

  • Node.js 18 or newer and npm, to run Vite. Check with node --version; if you are older, update.
  • Google Chrome, or any Chromium browser. Edge, Brave, and Arc all load the same unpacked extension.
  • Basic TypeScript. If you can read a typed function, you know enough. New to it? Our TypeScript cheat sheet is a quick refresher.

Set up the project

Create a fresh project. There are no runtime dependencies to install, only the build tooling:

bash
mkdir reading-time-counter
cd reading-time-counter
npm init -y
npm install -D vite typescript @types/chrome

vite bundles the TypeScript into the plain JavaScript the browser loads, typescript gives you type checking, and @types/chrome is the DefinitelyTyped package that types the whole chrome.* API surface so your editor autocompletes it. Open package.json, add "type": "module", and add these scripts:

json
{
  "name": "reading-time-counter",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "vite build",
    "typecheck": "tsc --noEmit"
  }
}

Add a tsconfig.json at the project root. The "types": ["chrome"] line is what pulls in the chrome.* types, and "lib": ["ES2022", "DOM"] gives you document and window for the content script and popup:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true,
    "lib": ["ES2022", "DOM"],
    "types": ["chrome"],
    "noEmit": true
  },
  "include": ["src", "vite.config.ts"]
}

Here is the folder layout we're building toward. Anything in public/ is copied into the build output untouched, which is how the manifest and the popup's HTML get there:

text
reading-time-counter/
  public/
    manifest.json   # the extension manifest (copied to dist as-is)
    popup.html      # popup markup (copied to dist as-is)
  src/
    background.ts   # the service worker
    content.ts      # runs on the page, counts the words
    popup.ts        # the popup logic
  package.json
  tsconfig.json
  vite.config.ts

Bundling with Vite

An extension is really three separate scripts (the popup, the service worker, and the content script) that never import from each other. So we give Vite three entry points and tell it to write each one to a fixed filename, because the manifest refers to background.js and content.js by name. By default Vite adds a hash to output filenames, which would break those references, so we turn hashing off. Create vite.config.ts:

typescript
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    outDir: "dist",
    emptyOutDir: true,
    rollupOptions: {
      // One entry per extension surface. Fixed output names (no hashes) so the
      // manifest can point at background.js and content.js by name.
      input: {
        popup: "src/popup.ts",
        background: "src/background.ts",
        content: "src/content.ts",
      },
      output: {
        entryFileNames: "[name].js",
        chunkFileNames: "[name].js",
        assetFileNames: "[name][extname]",
      },
    },
  },
});

Because the three entries share no imports, Vite emits exactly three files (popup.js, background.js, content.js) with no shared chunk. That matters for the content script especially: a script injected onto a page cannot use ES module import, so keeping it self-contained is not just tidy, it is required.

The manifest

The manifest is the extension's ID card: it tells Chrome the name, the version, which files do what, and which permissions to ask for. Create public/manifest.json:

json
{
  "manifest_version": 3,
  "name": "Reading Time & Word Counter",
  "version": "1.0.0",
  "description": "Counts the words on the current page and estimates reading time.",
  "action": {
    "default_popup": "popup.html",
    "default_title": "Reading Time & Word Counter"
  },
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "permissions": ["activeTab", "scripting", "storage"]
}

Three lines carry most of the meaning. action.default_popup is the HTML that opens when you click the toolbar icon. background.service_worker names the script Chrome runs in the background, and "type": "module" lets that worker use modern import syntax if you add it later. permissions is the exact list from the section above: three entries, nothing broad. Notice there is no content_scripts block. We inject the content script on demand from the worker instead, which is why scripting is in the list.

The content script: counting words

The content script is the only code that can see the page's DOM. Ours does one job: when it gets a COUNT_WORDS message, it reads the visible text, counts the words, works out a reading time, and sends the numbers back. Create src/content.ts:

typescript
// Injected into the active tab on demand. The IIFE plus a guard flag means
// that if we inject this file again on the next popup open, we do not register
// a second listener.
(() => {
  const w = window as unknown as { __wordCounterInjected?: boolean };
  if (w.__wordCounterInjected) return;
  w.__wordCounterInjected = true;

  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
    if (message && message.type === "COUNT_WORDS") {
      const text = document.body?.innerText ?? "";
      const words = text.trim().split(/\s+/).filter(Boolean).length;
      // 200 words per minute is the usual reading-speed estimate.
      const minutes = Math.max(1, Math.round(words / 200));
      sendResponse({ words, minutes });
    }
  });
})();

Two details make this reliable. The whole thing is wrapped in an immediately-invoked function with a __wordCounterInjected flag on window. Because we inject on demand, the same file can run more than once on a page, and without the guard you would stack up duplicate message listeners. The counting itself is plain string work: innerText gives you the visible text (not the raw HTML), .trim().split(/\s+/).filter(Boolean) splits on any run of whitespace and drops empties, so a blank page counts as zero rather than one. Math.max(1, ...) means even a short page reads as at least a minute.

Shaky on JavaScript or the DOM?

An extension is JavaScript plus a few browser APIs, so the stronger your JS, the easier this is. freeCodeCamp's JavaScript Algorithms and Data Structures and The Odin Project's Foundations path both teach the DOM and events for free. We line up more on our JavaScript courses page.

The background service worker

The service worker is the coordinator. The popup asks it for a count; the worker finds the active tab, makes sure the content script is present, asks it to count, and passes the answer back. Putting page access here (rather than in the popup) means every future surface, a keyboard shortcut or a context-menu item, can share the same one code path. Create src/background.ts:

typescript
interface CountResult {
  words: number;
  minutes: number;
}

chrome.runtime.onInstalled.addListener(() => {
  console.log("Reading Time & Word Counter installed.");
});

// The popup asks the worker for a count. The worker owns page access, so any
// surface we add later shares this one path.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message && message.type === "GET_COUNT") {
    countActiveTab()
      .then(sendResponse)
      .catch((error) => sendResponse({ error: String(error) }));
    return true; // keep the message channel open for the async reply
  }
  return false;
});

async function countActiveTab(): Promise<CountResult> {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) throw new Error("No active tab");

  // activeTab + scripting lets us inject on demand, with no broad host access.
  await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    files: ["content.js"],
  });

  return chrome.tabs.sendMessage(tab.id, { type: "COUNT_WORDS" });
}

The one line people forget is return true in the message listener. Our reply is async (we have to query a tab and inject a script first), and returning true tells Chrome to keep the message channel open until sendResponse is called. Drop it and the popup's request resolves to undefined before the count is ready. The chrome.scripting.executeScript call injects content.js right before we message it, so the counter is guaranteed to be on the page even if the tab was open before you installed the extension, a classic first-run bug when you rely on a declared content script instead.

The popup

The popup is a normal web page that Chrome shows in a little window under your icon. It needs markup and a script. First the markup, public/popup.html (it lives in public/ so it is copied to the build as-is):

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Word Counter</title>
    <style>
      body { width: 220px; margin: 0; padding: 16px; font-family: system-ui, sans-serif; }
      h1 { font-size: 14px; margin: 0 0 12px; }
      .stat { font-size: 28px; font-weight: 700; line-height: 1.1; }
      .label { font-size: 12px; color: #666; margin-bottom: 10px; }
      #status { font-size: 11px; color: #999; margin-top: 12px; }
    </style>
  </head>
  <body>
    <h1>Reading Time & Word Counter</h1>
    <div class="stat"><span id="words">0</span></div>
    <div class="label">words on this page</div>
    <div class="stat"><span id="time">0 min</span></div>
    <div class="label">estimated reading time</div>
    <div id="status">Counting...</div>
    <script type="module" src="popup.js"></script>
  </body>
</html>

The script tag points at popup.js, which is the built output of src/popup.ts. Now the logic, src/popup.ts. It shows the cached result first so the popup is never blank, then asks the worker for a fresh count and saves it:

typescript
interface CountResult {
  words?: number;
  minutes?: number;
  error?: string;
}

const wordsEl = document.getElementById("words");
const timeEl = document.getElementById("time");
const statusEl = document.getElementById("status");

function render(words: number, minutes: number): void {
  if (wordsEl) wordsEl.textContent = words.toLocaleString();
  if (timeEl) timeEl.textContent = minutes === 1 ? "1 min" : `${minutes} min`;
}

async function main(): Promise<void> {
  // Show the last cached result first, so the popup is never blank on open.
  const stored = await chrome.storage.local.get("lastResult");
  const cached = stored.lastResult as CountResult | undefined;
  if (cached && typeof cached.words === "number" && typeof cached.minutes === "number") {
    render(cached.words, cached.minutes);
  }

  try {
    const result = (await chrome.runtime.sendMessage({ type: "GET_COUNT" })) as CountResult;
    if (result && typeof result.words === "number" && typeof result.minutes === "number") {
      render(result.words, result.minutes);
      await chrome.storage.local.set({ lastResult: result });
      if (statusEl) statusEl.textContent = "Counted this page.";
    } else if (statusEl) {
      statusEl.textContent = "Can't read this page. Try a normal web page.";
    }
  } catch {
    if (statusEl) statusEl.textContent = "Can't read this page. Try a normal web page.";
  }
}

main();

This is where chrome.storage.local earns its place. Reading the cached lastResult first means the popup paints instantly with the previous number while the fresh count runs, which feels far snappier than a blank box. Storage is also the right home for this value precisely because the service worker forgets everything when it sleeps. The try/catch and the type guards handle the pages you cannot script: chrome:// settings pages, the New Tab page, and the Web Store all block injection, so instead of a broken-looking popup the user gets a plain "try a normal web page" note.

Build and load it

Build the extension:

bash
npm run build

Vite writes the finished extension to a dist/ folder: manifest.json and popup.html copied from public/, plus the three compiled scripts. Now load it into Chrome:

  • Open chrome://extensions in Chrome.
  • Turn on Developer mode with the toggle in the top right.
  • Click Load unpacked and select the dist/ folder.
  • The extension shows up in your toolbar. Pin it, open any article, and click the icon.

Open a long blog post and the popup reports a few thousand words and a reading time of several minutes; open a sparse page and the numbers drop. After you edit code, run npm run build again and hit the refresh icon on the extension's card in chrome://extensions to load the new build. That build-and-refresh loop is the whole development cycle.

Publishing to the Chrome Web Store

Loading unpacked is free and enough for personal use or a portfolio demo. To share your extension with the world through the Chrome Web Store, there is a one-time $5 developer registration fee, which Google charges to cut down on spam accounts. After that, you zip the dist/ folder, upload it in the Chrome Web Store Developer Dashboard, fill in a listing, and submit for review. Review usually takes a few days. One bonus: because the extension is standard Manifest V3, Microsoft Edge and Firefox both accept it with only small manifest tweaks, so you can reach all three stores from nearly the same code.

Want the full front-end picture?

This extension is a front-end project at heart: DOM, events, and a small UI. If you want to go from here to job-ready front-end skills, our free frontend learn path sequences the courses, and our pick of the best free JavaScript course for 2026 is a good next read.

Keep a reference handy

You now have a full extension in five short files: a manifest, a content script, a service worker, a popup, and a Vite config. Every extension you build from here is a variation on those pieces. Keep our TypeScript cheat sheet and JavaScript cheat sheet open while you work: most of what you write in an extension is plain TypeScript, with the chrome.* APIs sprinkled on top.

Where to go next

You have the core loop: read the page, do something with it, show the result, remember it. Three moves take this extension further.

Add a keyboard shortcut. Declare a commands block in the manifest and listen for it in the service worker, so users can count a page without opening the popup. The worker already owns the counting logic, so this is a small addition.

Store a history. Instead of caching only the last result, push each count into an array in chrome.storage.local and render a short list in the popup. That turns a one-shot tool into something you can look back on.

Try a bigger build. Ready to apply these skills to a full project? Our Build a Chrome Extension (free) path sequences the free courses that take you from here to a polished extension, and the frontend learn path covers the JavaScript and DOM skills the browser APIs sit on.

Frequently asked questions

Is Chrome extension development free?

Yes. Building an extension and loading it unpacked in Developer mode costs nothing: no account, no fee, no paid tools. The only cost is a one-time $5 developer registration if you decide to publish to the Chrome Web Store, and that is optional. Everything in this tutorial, including Vite and TypeScript, is free and open source.

Do I need to know Manifest V2?

No, and you should not learn it. Manifest V2 is deprecated and the Chrome Web Store no longer accepts new V2 extensions, so start with Manifest V3, which is what this tutorial uses. The main thing to know about the switch is that V3 replaced the old always-on background page with a service worker that sleeps when idle, which is why we cache state in chrome.storage instead of a global variable.

Can I build this with React instead of vanilla TypeScript?

Yes. The popup is just an HTML page, so you can render it with React, Vue, or Svelte by adding that framework and pointing Vite at a popup entry that mounts it. For a UI this small, plain TypeScript keeps the bundle tiny and the moving parts few, which is why we skip a framework here. Once your popup grows past a few controls, reaching for React is reasonable.

How long does Chrome Web Store review take?

Usually a few days for a simple extension, sometimes faster. Review time depends on which permissions you request and how much code the reviewers have to check, so an extension that asks for broad host access takes longer than one using activeTab like this one. Keeping your permissions minimal is the best way to speed up review, and it is better for users too.

Will this same code work in Firefox or Edge?

Mostly, yes. Microsoft Edge is Chromium-based and runs Chrome extensions almost unchanged. Firefox supports Manifest V3 too, with a few differences: it uses the browser.* namespace (though chrome.* is aliased in), and some manifest fields differ, so you may need small tweaks. For a first extension, target Chrome, then port to the others once it works.

Keep going on FreeCodingCourses