How to Build a Chrome Extension From Scratch in 2026 (The No-BS Beginner's Guide)
A step-by-step guide to building your first Chrome extension with Manifest V3. No fluff, just working code and real advice.
Saidul Islam
Author

I've built and published multiple Chrome extensions over the past year. Some hit the Chrome Web Store on the first try. Others got rejected and I had to learn the hard way what Google actually wants.
Here's the guide I wish I had when I started — no theory lectures, no "what is a browser extension" filler. Just the practical stuff you need to go from zero to a working extension you can actually publish.
What You'll Build
By the end of this guide, you'll have a fully functional Chrome extension that:
- Runs on Manifest V3 (the only version Google accepts now)
- Has a popup UI with real functionality
- Uses Chrome's storage API to save user data
- Works with content scripts to modify web pages
- Is ready to submit to the Chrome Web Store
I'm going to walk you through building a simple but genuinely useful extension: a Quick Notes tool that lets you take notes on any webpage and saves them per-URL. It's small enough to build in an afternoon but complex enough to teach you all the core concepts.
Prerequisites
You need three things:
- A text editor — VS Code is the obvious choice. If you're using something else, that's fine too.
- Basic JavaScript knowledge — You don't need to be an expert. If you can write functions and handle events, you're good.
- Chrome browser — Yeah, kind of obvious.
That's it. No build tools. No npm packages. No framework. Raw HTML, CSS, and JavaScript.
Step 1: Create Your Project Structure
Make a new folder. Call it quick-notes-extension. Inside it, create these files:
quick-notes-extension/
├── manifest.json
├── popup.html
├── popup.css
├── popup.js
├── content.js
├── background.js
└── icons/
├── icon-16.png
├── icon-48.png
└── icon-128.png
Every Chrome extension starts with manifest.json. Think of it as the extension's ID card — it tells Chrome what your extension does, what permissions it needs, and where to find its files.
Step 2: Write Your manifest.json
Here's the full manifest for our Quick Notes extension:
{
"manifest_version": 3,
"name": "Quick Notes",
"version": "1.0.0",
"description": "Take notes on any webpage. Notes are saved per URL.",
"permissions": ["storage", "activeTab"],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": []
}
],
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
Let me break down what matters here:
manifest_version: 3 — This is non-negotiable. Manifest V2 extensions can't be published anymore. V3 is the standard going forward.
permissions — Only request what you actually need. storage lets us save notes. activeTab gives us access to the current tab when the user clicks our icon. Google will reject your extension if you request broad permissions without justification.
action — This controls the toolbar icon and popup. When someone clicks your extension icon, popup.html opens.
background.service_worker — In V3, background pages are replaced by service workers. They're event-driven and don't persist in memory. This tripped me up at first — you can't store state in global variables because the service worker gets killed when idle.
content_scripts — These run inside web pages. We'll use ours to add a small visual indicator when a page has notes saved.
Step 3: Build the Popup UI
popup.html is what users see when they click your extension icon. Keep it clean:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<h1>Quick Notes</h1>
<p id="url-display" class="url"></p>
<textarea id="notes" placeholder="Type your notes here..."></textarea>
<div class="actions">
<button id="save-btn" class="btn primary">Save</button>
<button id="clear-btn" class="btn secondary">Clear</button>
</div>
<p id="status" class="status"></p>
</div>
<script src="popup.js"></script>
</body>
</html>
Now style it. popup.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 350px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #eee;
}
.container {
padding: 16px;
}
h1 {
font-size: 18px;
margin-bottom: 8px;
color: #a78bfa;
}
.url {
font-size: 11px;
color: #888;
margin-bottom: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
textarea {
width: 100%;
height: 150px;
padding: 10px;
border: 1px solid #333;
border-radius: 8px;
background: #16213e;
color: #eee;
font-size: 13px;
resize: vertical;
font-family: inherit;
}
textarea:focus {
outline: none;
border-color: #a78bfa;
}
.actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.btn {
flex: 1;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
font-weight: 600;
}
.btn.primary {
background: #a78bfa;
color: #1a1a2e;
}
.btn.primary:hover {
background: #8b5cf6;
}
.btn.secondary {
background: #333;
color: #ccc;
}
.btn.secondary:hover {
background: #444;
}
.status {
margin-top: 8px;
font-size: 12px;
color: #4ade80;
min-height: 16px;
}
Dark theme, purple accents. Looks sharp and modern without any framework.
Step 4: Add the Popup Logic
This is where the extension actually does something. popup.js:
document.addEventListener('DOMContentLoaded', async () => {
const notesEl = document.getElementById('notes');
const saveBtn = document.getElementById('save-btn');
const clearBtn = document.getElementById('clear-btn');
const statusEl = document.getElementById('status');
const urlDisplay = document.getElementById('url-display');
// Get the current tab's URL
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true
});
const pageUrl = tab.url;
urlDisplay.textContent = pageUrl;
// Load existing notes for this URL
const key = `notes_${pageUrl}`;
const stored = await chrome.storage.local.get(key);
if (stored[key]) {
notesEl.value = stored[key];
}
// Save notes
saveBtn.addEventListener('click', async () => {
const text = notesEl.value.trim();
if (text) {
await chrome.storage.local.set({ [key]: text });
statusEl.textContent = 'Saved!';
} else {
await chrome.storage.local.remove(key);
statusEl.textContent = 'Cleared (empty note)';
}
setTimeout(() => { statusEl.textContent = ''; }, 2000);
});
// Clear notes
clearBtn.addEventListener('click', async () => {
notesEl.value = '';
await chrome.storage.local.remove(key);
statusEl.textContent = 'Notes cleared';
setTimeout(() => { statusEl.textContent = ''; }, 2000);
});
// Auto-save on Ctrl+S / Cmd+S
notesEl.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveBtn.click();
}
});
});
A few things to notice:
chrome.tabs.query — Gets the active tab. This is the V3 way. Clean and async.
chrome.storage.local — Persists data locally. It survives browser restarts. We're using the page URL as the key, so each page gets its own notes.
Ctrl+S support — Little touches like this separate decent extensions from good ones. Users expect keyboard shortcuts.
Step 5: Set Up the Background Service Worker
background.js handles events when the popup isn't open:
// Update badge when notes exist for the current tab
chrome.tabs.onActivated.addListener(async (activeInfo) => {
const tab = await chrome.tabs.get(activeInfo.tabId);
await updateBadge(tab);
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete') {
await updateBadge(tab);
}
});
async function updateBadge(tab) {
if (!tab.url) return;
const key = `notes_${tab.url}`;
const stored = await chrome.storage.local.get(key);
if (stored[key]) {
await chrome.action.setBadgeText({
tabId: tab.id,
text: '✎'
});
await chrome.action.setBadgeBackgroundColor({
tabId: tab.id,
color: '#a78bfa'
});
} else {
await chrome.action.setBadgeText({
tabId: tab.id,
text: ''
});
}
}
This shows a small badge on the extension icon whenever the current page has notes. Users can see at a glance which pages they've annotated without clicking anything.
Service worker gotcha: Remember, this code doesn't run continuously. Chrome starts it when an event fires and kills it when idle. Don't rely on in-memory state. Always read from chrome.storage when you need data.
Step 6: Add the Content Script
content.js runs inside every webpage:
// Check if current page has notes and show a subtle indicator
(async () => {
const key = `notes_${window.location.href}`;
const stored = await chrome.storage.local.get(key);
if (stored[key]) {
const indicator = document.createElement('div');
indicator.id = 'quick-notes-indicator';
indicator.textContent = '📝';
indicator.title = 'You have notes on this page';
indicator.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
background: #1a1a2e;
border: 1px solid #a78bfa;
border-radius: 50%;
cursor: pointer;
z-index: 999999;
opacity: 0.7;
transition: opacity 0.2s;
`;
indicator.addEventListener('mouseenter', () => {
indicator.style.opacity = '1';
});
indicator.addEventListener('mouseleave', () => {
indicator.style.opacity = '0.7';
});
indicator.addEventListener('click', () => {
// No direct way to open popup programmatically,
// but we can show a tooltip with the note preview
const preview = stored[key].substring(0, 200);
indicator.title = preview + (stored[key].length > 200 ? '...' : '');
});
document.body.appendChild(indicator);
}
})();
Small floating emoji on pages with notes. Non-intrusive, but helpful.
Step 7: Create Icons
You need icons at 16x16, 48x48, and 128x128 pixels. For a quick prototype, you can:
- Use a free icon generator like Icon Kitchen
- Create a simple design in Figma
- Use a solid-color square with a letter as placeholder
For Quick Notes, a simple notepad icon in purple works great. Save them as PNG files in the icons/ folder.
Don't skip the 128px icon — it's required for the Chrome Web Store listing.
Step 8: Load and Test Your Extension
Here's the fun part:
- Open Chrome and navigate to
chrome://extensions - Toggle Developer mode (top right)
- Click Load unpacked
- Select your
quick-notes-extensionfolder
Your extension should appear in the extensions list. Pin it to the toolbar by clicking the puzzle piece icon and then the pin next to Quick Notes.
Now test it:
- Navigate to any website
- Click the extension icon
- Type some notes and hit Save
- Navigate away, then come back — your notes should still be there
- Check that the badge appears on pages with notes
- Try Ctrl+S to save
Debugging Tips
Popup not working? Right-click the extension icon → Inspect popup. Check the console for errors.
Content script issues? Open DevTools on any page (F12) and check the console. Content script logs appear there.
Service worker problems? Go to chrome://extensions, find your extension, and click "Service worker" to see its console.
Changes not showing? Click the refresh icon on your extension card in chrome://extensions. For content scripts, you also need to refresh the web page.
Step 9: Common Manifest V3 Gotchas
After building several extensions, here are the mistakes I see people make:
1. Requesting Too Many Permissions
Google reviews every permission. If you request <all_urls> or tabs without clear justification, expect rejection or a longer review. Only ask for what you use.
2. Using eval() or Remote Code
Manifest V3 has a strict Content Security Policy. You can't use eval(), new Function(), or load scripts from external URLs. Everything must be bundled in your extension.
3. Forgetting Service Workers Are Ephemeral
This is the biggest V3 adjustment. Your background service worker will be terminated after ~30 seconds of inactivity. Use chrome.storage instead of global variables. Use chrome.alarms instead of setTimeout for anything longer than 30 seconds.
4. Not Handling the chrome:// Pages
Content scripts don't run on chrome:// pages, the Chrome Web Store, or other extension pages. Your extension should handle these gracefully instead of throwing errors.
5. Missing Error Handling
chrome.runtime.lastError still exists but async/await with try-catch is cleaner in V3:
try {
const result = await chrome.storage.local.get('key');
// use result
} catch (err) {
console.error('Storage error:', err);
}
Step 10: Prepare for the Chrome Web Store
Ready to publish? Here's what you need:
- Developer account — One-time $5 fee at the Chrome Web Store Developer Dashboard
- Screenshots — At least one 1280x800 or 640x400 screenshot
- Description — Clear, honest, no keyword stuffing
- Privacy policy — Required if you use any permissions. A simple GitHub Pages privacy policy works.
- ZIP file — Zip your extension folder (not the parent folder — the manifest.json should be at the root of the ZIP)
Store Review Timeline
In my experience, first submissions take 1-3 business days. Updates are usually faster, sometimes within hours. If your extension gets rejected, you'll get specific feedback on what to fix.
What to Build Next
Once you've got the basics down, here are some ideas to level up:
- Options page — Let users customize behavior (
chrome.options_page) - Context menus — Right-click actions (
chrome.contextMenus) - Cross-device sync — Use
chrome.storage.syncinstead oflocal - Side panel — Chrome's newer Side Panel API gives you more screen real estate
- Keyboard shortcuts — Define in manifest under
commands
The Chrome Extension ecosystem is genuinely one of the best opportunities in software right now. Distribution is built in (the Chrome Web Store), the user base is massive (3+ billion Chrome users), and most extensions are surprisingly simple once you understand the architecture.
Wrapping Up
Building a Chrome extension isn't complicated. The hardest part is usually the manifest configuration and understanding the V3 service worker lifecycle. Once those click, you can build almost anything.
The Quick Notes extension we built covers all the core concepts: popup UI, storage, background workers, content scripts, and badges. Every extension I've built — from AI Chat Organizer to productivity tools — uses these same building blocks.
If you want to see more extensions built with these principles, check out the NexaSphere products page. And if you build something cool, I'd love to hear about it.
Start small, ship fast, and iterate. That's how the best extensions get built.
Get more insights like this
Join our newsletter for weekly deep dives on AI tools, Chrome extensions, and software engineering.