Building Chrome Extensions with TypeScript: A No-Fluff Guide
What You’ll Learn
- The parts of Chrome extension development that actually matter
- How I think about Manifest V3 in practical terms
- A simple extension structure using TypeScript
- When logic belongs in a content script, service worker, or popup
- The mistakes that make browser extensions fragile fast
Chrome extensions are one of the easiest ways to ship a useful tool quickly, but they are also one of the easiest places to create a messy codebase if you start copying random tutorial patterns.
Most “getting started” guides either overexplain the basics or skip straight to toy examples that do not resemble a real product.
So here is the version I actually care about.
If I am building a Chrome extension today, I want three things:
- clear separation between UI and background logic
- a maintainable TypeScript setup
- a Manifest V3 shape that I can extend without regret
The Three Main Pieces You Need to Understand
For most extensions, you only need to think clearly about three runtimes:
- popup or extension UI
- content scripts
- background service worker
The popup is for user-facing controls. The content script runs in the context of matching pages. The background service worker handles extension-level behavior like messaging, storage coordination, and browser events.
If you confuse those roles early, the project gets ugly fast.
A Minimal MV3 Manifest Shape
This is the kind of starting point I like:
{
"manifest_version": 3,
"name": "Example Extension",
"version": "1.0.0",
"action": {
"default_popup": "popup.html"
},
"background": {
"service_worker": "background.js"
},
"permissions": ["storage", "activeTab", "scripting"],
"host_permissions": ["https://*/*", "http://*/*"],
"content_scripts": [
{
"matches": ["https://*/*", "http://*/*"],
"js": ["content.js"]
}
]
}
This is enough for a lot of real extensions.
Do not start with every permission under the sun. Ask for the smallest set the feature actually needs.
My Default Project Shape
I usually structure the extension like this:
src/
background/
content/
popup/
shared/
The shared folder is where types, message contracts, and small utilities live.
That matters because messaging between runtimes is one of the first places extension code becomes inconsistent.
Use Typed Message Contracts Early
If the popup talks to the background service worker, I want that interface to be explicit.
For example:
export type ExtensionMessage =
| { type: 'SAVE_NOTE'; payload: { text: string } }
| { type: 'GET_NOTE' };
Then background logic can stay focused:
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'SAVE_NOTE') {
chrome.storage.local.set({ note: message.payload.text }).then(() => {
sendResponse({ ok: true });
});
return true;
}
if (message.type === 'GET_NOTE') {
chrome.storage.local.get('note').then((result) => {
sendResponse({ ok: true, note: result.note ?? '' });
});
return true;
}
});
And the popup side stays simple:
await chrome.runtime.sendMessage({
type: 'SAVE_NOTE',
payload: { text: 'hello from popup' },
});
This is not fancy, but it prevents a lot of extension entropy.
Put DOM Automation in Content Scripts, Not Everywhere
If the extension reads or manipulates a page, that belongs in a content script.
I do not want popup code scraping the DOM indirectly or background code pretending it has page context. That only makes the system harder to reason about.
A content script should do page-local work:
const title = document.querySelector('h1')?.textContent?.trim() ?? '';
chrome.runtime.sendMessage({
type: 'SAVE_NOTE',
payload: { text: title },
});
Keep content scripts focused on the page. Keep shared logic somewhere else.
The Main MV3 Mindset Shift
Manifest V3 pushes you toward a more event-driven model because the background logic runs as a service worker.
That means you should not design the extension like a permanently running desktop app living in the browser. Instead, think in terms of:
- events
- messages
- persisted state
- resumable logic
This is usually a good thing. It encourages smaller moving parts.
The Mistakes I Avoid
Asking for too many permissions too early
This makes the extension harder to review, harder to trust, and often harder to publish.
Mixing runtime responsibilities
Popup, content script, and background worker should not all try to own the same logic.
Leaving message payloads untyped
This is one of the fastest ways for extension code to become inconsistent.
Building a browser extension for something that should just be a web app
I only pick the extension route when browser context is part of the value.
Final Thought
Chrome extensions are one of the best ways to ship small, high-leverage tools, but only if the architecture stays simple.
Keep the permissions tight, separate the runtimes cleanly, use TypeScript contracts for messages, and let MV3’s event-driven model work for you instead of against you.
If you need help building browser extensions, internal tooling, or fast TypeScript products that live close to real user workflows, take a look at my portfolio: voidcraft-site.vercel.app.