Open to SWE / AI Engineering roles β€” Let's talk
Skip to content

Build Your First Chrome Extension with TypeScript from Scratch

AI engineeringMay 1, 2026

A modern extension starts with a typed build loop.

Build Your First Chrome Extension with TypeScript from Scratch

Most Chrome extension tutorials still use plain JavaScript with no build tools. That works for a quick hack, but the moment your extension grows past one file, you'll want types, imports, and hot reload.

This guide gets you from an empty folder to a working extension β€” with TypeScript, Vite, and CRXJS β€” in under 30 minutes.

New to how extensions are structured? Read How Chrome Extensions Actually Work in 2026 (Manifest V3 Explained) first for the mental model behind popups, content scripts, and service workers.

What You'll Build

A popup extension that shows the current tab's title and URL. Simple, but it touches every part of the stack: manifest.json, a popup UI, and the chrome.tabs API.

Prerequisites

  • Node.js 18+
  • A Chromium browser (Chrome, Edge, Brave)
  • Basic TypeScript knowledge

1. Scaffold the Project

npm create vite@latest my-first-extension -- --template vanilla-ts
cd my-first-extension
npm install
npm install -D @crxjs/vite-plugin@latest chrome-types

@crxjs/vite-plugin handles the Manifest V3 build pipeline β€” it watches your files, rebuilds on save, and hot-reloads the extension in the browser. chrome-types gives you full type definitions for every chrome.* API.

2. Create the Manifest

Delete everything inside src/ and create manifest.json at the project root:

{
  "manifest_version": 3,
  "name": "Tab Info",
  "version": "1.0.0",
  "description": "Shows the current tab's title and URL",
  "action": {
    "default_popup": "src/popup.html"
  },
  "permissions": ["activeTab"]
}

Key decisions here:

  • manifest_version: 3 β€” MV2 is no longer accepted on the Chrome Web Store as of late 2024.
  • action.default_popup β€” This tells Chrome to show a popup when the user clicks the extension icon.
  • permissions: ["activeTab"] β€” The least-privilege way to access the current tab. No scary permission warnings for users. More on this in Chrome Extension Permissions: How to Ask for Less and Ship Faster.

3. Configure Vite

Replace vite.config.ts:

import { defineConfig } from "vite";
import { crx } from "@crxjs/vite-plugin";
import manifest from "./manifest.json";

export default defineConfig({
  plugins: [crx({ manifest })],
});

That's the entire config. CRXJS reads your manifest.json and wires everything up β€” entry points, output structure, HMR.

4. Build the Popup

Create src/popup.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <style>
      body {
        width: 320px;
        padding: 16px;
        font-family: system-ui, sans-serif;
      }
      h1 {
        font-size: 16px;
        margin: 0 0 12px;
      }
      p {
        font-size: 13px;
        color: #555;
        word-break: break-all;
      }
    </style>
  </head>
  <body>
    <h1 id="title">Loading...</h1>
    <p id="url"></p>
    <script type="module" src="./popup.ts"></script>
  </body>
</html>

Create src/popup.ts:

async function showTabInfo(): Promise<void> {
  const [tab] = await chrome.tabs.query({
    active: true,
    currentWindow: true,
  });

  const titleEl = document.getElementById("title") as HTMLHeadingElement;
  const urlEl = document.getElementById("url") as HTMLParagraphElement;

  titleEl.textContent = tab.title ?? "No title";
  urlEl.textContent = tab.url ?? "No URL";
}

showTabInfo();

chrome.tabs.query returns an array of tabs. We destructure the first one β€” that's always the active tab in the current window when you're using activeTab permission.

5. Load and Test

npm run dev

Then in Chrome:

  1. Open chrome://extensions
  2. Enable "Developer mode" (top right)
  3. Click "Load unpacked"
  4. Select the dist folder inside your project

Click the extension icon in the toolbar. You should see the current tab's title and URL in the popup.

CRXJS gives you hot reload β€” edit popup.ts, save, and the popup updates without manually reloading the extension.

6. Add Type Safety for Chrome APIs

The chrome-types package you installed gives you global types. To make sure TypeScript sees them, add to tsconfig.json:

{
  "compilerOptions": {
    "types": ["chrome-types"]
  }
}

Now chrome.tabs.query, chrome.storage, chrome.runtime β€” everything is fully typed. No more guessing at callback signatures.

Project Structure

After these steps, your project looks like this:

my-first-extension/
β”œβ”€β”€ manifest.json
β”œβ”€β”€ vite.config.ts
β”œβ”€β”€ tsconfig.json
β”œβ”€β”€ package.json
└── src/
    β”œβ”€β”€ popup.html
    └── popup.ts

Clean and minimal. As your extension grows, you'll add:

  • A src/background.ts service worker for event handling
  • A src/content.ts content script for DOM manipulation
  • Possibly a src/options.html page for settings

That's when message passing and storage become critical β€” but that's the next step.

Common Gotchas

CRXJS version mismatch. Make sure you're on @crxjs/vite-plugin@latest and Vite 5+. Older versions have MV3 compatibility issues.

Popup width. Chrome enforces a max popup width of 800px and max height of 600px. Set an explicit width on your <body> or the popup will auto-size to content.

activeTab only works on click. The activeTab permission grants access only when the user clicks the extension icon. If you need always-on access, you need host permissions β€” but think twice before adding them.

What's Next

You've got a working extension with TypeScript and hot reload. From here:

  1. Understand the architecture β€” How Chrome Extensions Actually Work in 2026 covers the mental model for MV3's four components.
  2. Add communication β€” Chrome Extension Message Passing with TypeScript tackles the trickiest part: getting your popup, background, and content scripts to talk to each other with type safety.
  3. Persist data β€” How to Persist State in a Chrome Extension shows why global variables die in service workers and how to use chrome.storage properly.

This is part of the Chrome Extensions from Zero to Product series, where I walk through everything I learned building ResistGate and Amethyst β€” from first popup to published product.

Building in public

Follow the journey

as I build AI tools, products, and a serious founder life.

No spam. Unsubscribe anytime.

Build Your First Chrome Extension with TypeScript from Scratch | Orlando Ascanio