Build Your First Chrome Extension with TypeScript from Scratch
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 β meaning you discover type errors only after installing the broken extension in the browser. That feedback loop is painful.
This guide gets you from an empty folder to a working extension β with TypeScript, Vite, and CRXJS β in under 30 minutes. It's part of the Chrome Extensions from Zero to Product series, where I document everything I learned building ResistGate and Amethyst.
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β 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 titleEl = document.getElementById("title") as HTMLHeadingElement;
const urlEl = document.getElementById("url") as HTMLParagraphElement;
try {
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
titleEl.textContent = tab.title ?? "No title";
urlEl.textContent = tab.url ?? "No URL";
} catch (err) {
titleEl.textContent = "Error loading tab info";
urlEl.textContent = err instanceof Error ? err.message : "Unknown error";
}
}
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 using the activeTab permission. The try-catch ensures users never see a frozen "Loading..." if the API call fails.
5. Add Type Safety for Chrome APIs
The chrome-types package gives you global types for every chrome.* API. Update your tsconfig.json so TypeScript sees them:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["chrome-types"]
},
"include": ["src"]
}
The key addition is "types": ["chrome-types"] inside compilerOptions. Now chrome.tabs.query, chrome.storage, chrome.runtime β everything is fully typed. Your editor will autocomplete method signatures and flag misuse at write time, not after you install the broken build.
6. Validate the Build
Before loading into the browser, verify the build succeeds:
npm run build
A successful build creates a dist/ folder with your compiled extension. If this step fails, fix the error here β it's much faster than debugging a broken load in Chrome.
7. Load and Test
npm run dev
Then in Chrome:
- Open
chrome://extensions - Enable Developer mode (toggle, top right)
- Click Load unpacked
- Select the
dist/folder inside your project β not the project root
Click the extension icon in the toolbar. You'll see a small popup with the current tab's title on one line and the full URL below it.
CRXJS gives you hot reload β edit popup.ts, save, and the popup updates without manually reloading the extension.
If the popup doesn't appear:
- Confirm
dist/exists (runnpm run buildif not) - Open DevTools on the popup (right-click the popup β Inspect) and check the Console tab
- Verify Developer mode is enabled on
chrome://extensions - Check the Extensions page for any error badges on your extension card
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
CRXJS outputs to dist/ with a structure Chrome expects β separate files for each entry point, a processed manifest, and any assets. You load dist/ as an unpacked extension; the root folder won't work because Chrome reads from the compiled output, not your source.
Common Gotchas
CRXJS version mismatch. Use @crxjs/vite-plugin@latest with Vite 5+. Older versions have MV3 compatibility issues.
Popup size. Chrome enforces a max popup width of 800px and max height of 600px. Set an explicit width on <body> or the popup auto-sizes to content in unexpected ways.
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.
types nesting. The "types": ["chrome-types"] entry goes inside "compilerOptions", not at the root of tsconfig.json. A common mistake that produces confusing errors.
What's Next
You've got a working extension with TypeScript and hot reload. From here:
- Understand the architecture β How Chrome Extensions Actually Work in 2026 covers the mental model for MV3's four components.
- 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.
- Persist data β How to Persist State in a Chrome Extension shows why global variables die in service workers and how to use
chrome.storageproperly.
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.