Build Your First Chrome Extension with TypeScript from Scratch
Vite, CRXJS, and TypeScript turn an empty folder into a working MV3 popup faster than copy-pasting manifest snippets.
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 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" (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
Clean and minimal. As your extension grows, you'll add:
- A
src/background.tsservice worker for event handling - A
src/content.tscontent script for DOM manipulation - Possibly a
src/options.htmlpage 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:
- 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.
Notes from the build
More from the Chrome extension trenches
Architecture, storage, message passing, and real lessons from building ResistGate and Amethyst. No fluff.
By subscribing, you agree to receive Orlando's emails. No spam. Unsubscribe anytime.