Build Your First Chrome Extension: A Practical Step-by-Step Guide
Learn to build a website bookmarker Chrome extension from scratch using Manifest V3. Covers popup UI, content scripts, service workers, Chrome storage, and publishing.
Why Build a Chrome Extension?
I have been building Chrome extensions on and off for about four years now. My first one was embarrassingly simple — it just changed the background color of every webpage to dark mode before dark mode was widely supported. But even that tiny project taught me more about how browsers work than any tutorial on web development ever did.
Chrome extensions sit at a fascinating intersection: they are basically web apps (HTML, CSS, JavaScript) that have superpowers. They can read and modify any webpage, store data locally, communicate with external APIs, and run background tasks. And with over 3 billion Chrome users worldwide, the distribution potential is enormous.
We are going to build something practical — a Website Bookmarker extension that lets you save any webpage with custom tags, search through your bookmarks, and export them. Along the way, you will learn every major concept in Chrome extension development: manifest configuration, popup UIs, content scripts, background service workers, the Chrome Storage API, and message passing between components.
By the end, you will have a fully working extension and the knowledge to build whatever comes to mind next.
Setting Up the Project
Create a new folder for your extension. The structure will look like this:
quick-bookmarker/
├── manifest.json
├── popup/
│ ├── popup.html
│ ├── popup.css
│ └── popup.js
├── background/
│ └── service-worker.js
├── content/
│ └── content.js
├── icons/
│ ├── icon16.png
│ ├── icon48.png
│ └── icon128.png
└── utils/
└── storage.js
Every Chrome extension starts with a manifest.json file. This is the configuration that tells Chrome what your extension does, what permissions it needs, and what files it uses.
The Manifest File (V3)
Manifest V3 is the current standard. If you find tutorials using Manifest V2 with background.scripts or browser_action, those are outdated. Chrome has been enforcing V3 since 2024, and V2 extensions can no longer be published to the Chrome Web Store.
{
"manifest_version": 3,
"name": "Quick Bookmarker",
"version": "1.0.0",
"description": "Save and organize website bookmarks with custom tags",
"permissions": [
"storage",
"activeTab",
"tabs"
],
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background/service-worker.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content/content.js"],
"run_at": "document_idle"
}
],
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
Let me break down the key parts:
- manifest_version: 3 — Required. No negotiation here.
- permissions —
storagelets us save data;activeTabgives access to the current tab when the user clicks our extension;tabslets us query tab information. - action — Defines the popup that appears when clicking the extension icon in the toolbar.
- background.service_worker — Replaces the old background pages. Service workers are event-driven and do not persist in memory, which makes them more efficient but also trickier to work with.
- content_scripts — JavaScript that runs directly inside webpages. This is how we interact with page content.
A Note on Permissions
Request only what you need. Every permission you add is shown to users during installation, and excessive permissions scare people away. The activeTab permission is particularly useful because it gives you temporary access to the current tab only when the user actively interacts with your extension — no blanket access to all browsing data.
Building the Popup UI
The popup is what users see when they click your extension icon. It is a regular HTML page, but with some constraints: it lives in a small window (max 800x600 pixels by default) and has its own isolated DOM.
popup.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="popup.css">
<title>Quick Bookmarker</title>
</head>
<body>
<div class="container">
<header>
<h1>Quick Bookmarker</h1>
<button id="saveBtn" class="btn btn-primary">
+ Save Current Page
</button>
</header>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Search bookmarks..." />
</div>
<div id="saveForm" class="save-form hidden">
<input type="text" id="titleInput" placeholder="Page title" />
<input type="text" id="tagsInput" placeholder="Tags (comma separated)" />
<textarea id="notesInput" placeholder="Notes (optional)"></textarea>
<div class="form-actions">
<button id="confirmSave" class="btn btn-primary">Save</button>
<button id="cancelSave" class="btn btn-secondary">Cancel</button>
</div>
</div>
<div id="bookmarkList" class="bookmark-list">
<!-- Bookmarks will be rendered here -->
</div>
<footer>
<button id="exportBtn" class="btn btn-secondary">Export All</button>
<span id="countLabel">0 bookmarks</span>
</footer>
</div>
<script src="../utils/storage.js"></script>
<script src="popup.js"></script>
</body>
</html>
popup.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 380px;
max-height: 520px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
overflow-y: auto;
}
.container {
padding: 16px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
h1 {
font-size: 16px;
font-weight: 600;
color: #f1f5f9;
}
.btn {
border: none;
border-radius: 6px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: #1e293b;
color: #94a3b8;
border: 1px solid #334155;
}
.btn-secondary:hover {
background: #334155;
}
.search-bar input {
width: 100%;
padding: 10px 12px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
font-size: 13px;
margin-bottom: 12px;
outline: none;
}
.search-bar input:focus {
border-color: #3b82f6;
}
.save-form {
background: #1e293b;
padding: 12px;
border-radius: 8px;
margin-bottom: 12px;
}
.save-form input,
.save-form textarea {
width: 100%;
padding: 8px 10px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 4px;
color: #e2e8f0;
font-size: 13px;
margin-bottom: 8px;
outline: none;
font-family: inherit;
}
.save-form textarea {
height: 60px;
resize: vertical;
}
.form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.hidden {
display: none;
}
.bookmark-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.bookmark-item {
background: #1e293b;
padding: 10px 12px;
border-radius: 6px;
border-left: 3px solid #3b82f6;
}
.bookmark-item .title {
font-size: 13px;
font-weight: 500;
color: #f1f5f9;
text-decoration: none;
display: block;
margin-bottom: 4px;
}
.bookmark-item .title:hover {
color: #3b82f6;
}
.bookmark-item .tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.tag {
font-size: 11px;
background: #0f172a;
color: #94a3b8;
padding: 2px 8px;
border-radius: 10px;
}
.bookmark-item .meta {
font-size: 11px;
color: #64748b;
display: flex;
justify-content: space-between;
}
.delete-btn {
background: none;
border: none;
color: #64748b;
cursor: pointer;
font-size: 12px;
}
.delete-btn:hover {
color: #ef4444;
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
border-top: 1px solid #1e293b;
}
#countLabel {
font-size: 12px;
color: #64748b;
}
The Storage Utility
Before writing the popup logic, let us create a reusable storage module. Chrome's storage.local API is asynchronous and stores data persistently across browser sessions.
utils/storage.js
const BookmarkStorage = {
async getAll() {
const result = await chrome.storage.local.get('bookmarks');
return result.bookmarks || [];
},
async save(bookmark) {
const bookmarks = await this.getAll();
bookmark.id = Date.now().toString();
bookmark.createdAt = new Date().toISOString();
bookmarks.unshift(bookmark);
await chrome.storage.local.set({ bookmarks });
return bookmark;
},
async remove(id) {
const bookmarks = await this.getAll();
const filtered = bookmarks.filter(b => b.id !== id);
await chrome.storage.local.set({ bookmarks: filtered });
},
async search(query) {
const bookmarks = await this.getAll();
const lower = query.toLowerCase();
return bookmarks.filter(b =>
b.title.toLowerCase().includes(lower) ||
b.url.toLowerCase().includes(lower) ||
b.tags.some(t => t.toLowerCase().includes(lower)) ||
(b.notes && b.notes.toLowerCase().includes(lower))
);
},
async exportAll() {
const bookmarks = await this.getAll();
return JSON.stringify(bookmarks, null, 2);
},
async getCount() {
const bookmarks = await this.getAll();
return bookmarks.length;
}
};
A couple of things to note here. We are using chrome.storage.local instead of localStorage. The difference is important: chrome.storage.local persists across sessions, syncs between extension components (popup, background, content scripts), and can store up to 10MB of data. Regular localStorage in a popup is scoped to that popup and disappears when it closes.
The chrome.storage.sync variant would sync data across the user's Chrome browsers via their Google account, but it has a much smaller storage limit (100KB total). For a bookmarker that could store hundreds of entries, local is the right choice.
Popup Logic
popup.js
document.addEventListener('DOMContentLoaded', async () => {
const saveBtn = document.getElementById('saveBtn');
const saveForm = document.getElementById('saveForm');
const confirmSave = document.getElementById('confirmSave');
const cancelSave = document.getElementById('cancelSave');
const searchInput = document.getElementById('searchInput');
const bookmarkList = document.getElementById('bookmarkList');
const exportBtn = document.getElementById('exportBtn');
const countLabel = document.getElementById('countLabel');
// Load bookmarks on open
await renderBookmarks();
// Save button toggles the form
saveBtn.addEventListener('click', async () => {
saveForm.classList.toggle('hidden');
if (!saveForm.classList.contains('hidden')) {
// Pre-fill with current tab info
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true
});
document.getElementById('titleInput').value = tab.title || '';
document.getElementById('tagsInput').value = '';
document.getElementById('notesInput').value = '';
}
});
cancelSave.addEventListener('click', () => {
saveForm.classList.add('hidden');
});
// Confirm save
confirmSave.addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true
});
const title = document.getElementById('titleInput').value.trim();
const tagsRaw = document.getElementById('tagsInput').value.trim();
const notes = document.getElementById('notesInput').value.trim();
if (!title) return;
const tags = tagsRaw
? tagsRaw.split(',').map(t => t.trim()).filter(Boolean)
: [];
await BookmarkStorage.save({
title,
url: tab.url,
tags,
notes,
favicon: tab.favIconUrl || ''
});
saveForm.classList.add('hidden');
await renderBookmarks();
// Notify background to update badge
chrome.runtime.sendMessage({ type: 'BOOKMARK_SAVED' });
});
// Search with debounce
let searchTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
const query = searchInput.value.trim();
if (query) {
const results = await BookmarkStorage.search(query);
renderBookmarkItems(results);
} else {
await renderBookmarks();
}
}, 250);
});
// Export
exportBtn.addEventListener('click', async () => {
const data = await BookmarkStorage.exportAll();
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bookmarks-export.json';
a.click();
URL.revokeObjectURL(url);
});
// Render functions
async function renderBookmarks() {
const bookmarks = await BookmarkStorage.getAll();
renderBookmarkItems(bookmarks);
const count = bookmarks.length;
countLabel.textContent = `${count} bookmark${count !== 1 ? 's' : ''}`;
}
function renderBookmarkItems(bookmarks) {
if (bookmarks.length === 0) {
bookmarkList.innerHTML = `
<div style="text-align:center; padding:24px; color:#64748b; font-size:13px;">
No bookmarks yet. Save your first page!
</div>`;
return;
}
bookmarkList.innerHTML = bookmarks.map(b => `
<div class="bookmark-item" data-id="${b.id}">
<a href="${b.url}" target="_blank" class="title">${escapeHtml(b.title)}</a>
<div class="tags">
${b.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
</div>
<div class="meta">
<span>${formatDate(b.createdAt)}</span>
<button class="delete-btn" data-id="${b.id}">Delete</button>
</div>
</div>
`).join('');
// Attach delete handlers
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await BookmarkStorage.remove(btn.dataset.id);
await renderBookmarks();
chrome.runtime.sendMessage({ type: 'BOOKMARK_DELETED' });
});
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleDateString('en-IN', {
day: 'numeric', month: 'short', year: 'numeric'
});
}
});
The popup script does several things: it renders the bookmark list on open, handles saving new bookmarks with tag parsing, implements debounced search, and provides an export feature that creates a downloadable JSON file. Notice how we use chrome.tabs.query to get the current tab information — this is why we need the tabs permission.
Background Service Worker
The service worker runs in the background without a visible page. In Manifest V3, it is event-driven — it wakes up when events occur and goes to sleep when idle. This is fundamentally different from V2's persistent background pages.
background/service-worker.js
// Update badge count when bookmarks change
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'BOOKMARK_SAVED' || message.type === 'BOOKMARK_DELETED') {
updateBadgeCount();
}
});
// Set badge count on extension install/update
chrome.runtime.onInstalled.addListener(() => {
updateBadgeCount();
});
// Also update on browser startup
chrome.runtime.onStartup.addListener(() => {
updateBadgeCount();
});
async function updateBadgeCount() {
const result = await chrome.storage.local.get('bookmarks');
const count = (result.bookmarks || []).length;
await chrome.action.setBadgeText({
text: count > 0 ? count.toString() : ''
});
await chrome.action.setBadgeBackgroundColor({
color: '#3b82f6'
});
}
// Context menu for right-click saving
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'quickSave',
title: 'Save to Quick Bookmarker',
contexts: ['page', 'link']
});
});
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === 'quickSave') {
const url = info.linkUrl || info.pageUrl;
const title = tab.title || url;
const result = await chrome.storage.local.get('bookmarks');
const bookmarks = result.bookmarks || [];
bookmarks.unshift({
id: Date.now().toString(),
title,
url,
tags: ['quick-save'],
notes: '',
favicon: tab.favIconUrl || '',
createdAt: new Date().toISOString()
});
await chrome.storage.local.set({ bookmarks });
updateBadgeCount();
}
});
The service worker adds two nice features: a badge count showing how many bookmarks you have saved, and a context menu option to quickly save a page or link with a right-click. Notice the use of chrome.contextMenus — you need to add "contextMenus" to your permissions array in manifest.json for this to work.
Update your manifest permissions:
"permissions": [
"storage",
"activeTab",
"tabs",
"contextMenus"
]
Content Script
Content scripts run inside web pages. They can read and modify the DOM of any page that matches the URL pattern defined in the manifest. For our bookmarker, we will use a content script to extract metadata from the page.
content/content.js
// Listen for messages from the popup or background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_PAGE_META') {
const meta = extractPageMetadata();
sendResponse(meta);
}
return true; // Keep the message channel open for async response
});
function extractPageMetadata() {
const meta = {
title: document.title,
description: '',
keywords: [],
ogImage: '',
author: ''
};
// Meta description
const descTag = document.querySelector('meta[name="description"]')
|| document.querySelector('meta[property="og:description"]');
if (descTag) {
meta.description = descTag.getAttribute('content') || '';
}
// Keywords
const keywordsTag = document.querySelector('meta[name="keywords"]');
if (keywordsTag) {
meta.keywords = keywordsTag.getAttribute('content')
.split(',')
.map(k => k.trim())
.filter(Boolean);
}
// OG Image
const ogImage = document.querySelector('meta[property="og:image"]');
if (ogImage) {
meta.ogImage = ogImage.getAttribute('content') || '';
}
// Author
const authorTag = document.querySelector('meta[name="author"]');
if (authorTag) {
meta.author = authorTag.getAttribute('content') || '';
}
return meta;
}
This content script listens for a GET_PAGE_META message and responds with metadata extracted from the page's meta tags. The popup could use this to auto-populate tags based on the page's keywords or show a preview image.
Message Passing Between Components
Message passing is how different parts of your extension communicate. Here is a quick reference:
| From | To | Method |
|---|---|---|
| Popup → Background | chrome.runtime.sendMessage() | Background listens with chrome.runtime.onMessage |
| Background → Content | chrome.tabs.sendMessage(tabId, msg) | Content listens with chrome.runtime.onMessage |
| Popup → Content | chrome.tabs.sendMessage(tabId, msg) | Content listens with chrome.runtime.onMessage |
| Content → Background | chrome.runtime.sendMessage() | Background listens with chrome.runtime.onMessage |
The important detail: content scripts and the popup share the same chrome.runtime.sendMessage API to send messages, but they run in completely different contexts. Content scripts run inside the webpage's context (with some isolation), while the popup runs in the extension's context.
Testing and Debugging Your Extension
Loading the Extension
- Open Chrome and navigate to
chrome://extensions/ - Enable Developer mode (toggle in the top right)
- Click Load unpacked and select your extension folder
- Your extension icon should appear in the toolbar
Debugging Tips
- Popup: Right-click the extension icon → Inspect popup. This opens DevTools for the popup, where you can see console logs, set breakpoints, and inspect the DOM.
- Service worker: On the
chrome://extensions/page, click Inspect views: service worker under your extension. This opens DevTools for the background script. - Content scripts: Open DevTools on any page (F12), go to the Sources tab, and look under Content scripts in the file tree. You can set breakpoints directly in your content script code.
- Storage inspection: In any extension DevTools console, run
chrome.storage.local.get(null, console.log)to dump all stored data.
Common Mistakes to Avoid
- Forgetting to reload. After changing manifest.json, you must click the reload button on
chrome://extensions/. Changes to popup HTML/CSS/JS take effect on the next popup open, but manifest changes need an explicit reload. - Service worker going to sleep. V3 service workers are not persistent. Do not store state in global variables — use
chrome.storageinstead. The service worker can terminate and restart at any time. - CSP violations. Extensions have a strict Content Security Policy. You cannot use inline scripts (
onclick="...") oreval(). All JavaScript must be in separate .js files. - Manifest permission errors. If an API call fails silently, check that you have the required permission in manifest.json. Chrome does not always throw helpful error messages for missing permissions.
Publishing to the Chrome Web Store
Once your extension works locally, publishing it is straightforward but involves a few steps:
- Create a developer account at the Chrome Web Store Developer Dashboard. There is a one-time $5 registration fee.
- Prepare your assets:
- Extension icons at 128x128 pixels
- At least one screenshot (1280x800 or 640x400)
- A promotional tile image (440x280)
- A detailed description (this affects search ranking)
- Package your extension — zip your extension folder (not the parent folder — the zip should contain manifest.json at the root level).
- Upload and fill in details — category, language, pricing (free or paid).
- Submit for review — Google reviews all submissions. Simple extensions typically get approved within 1-3 business days. Extensions requesting powerful permissions like
<all_urls>orwebRequestface more scrutiny.
Review Rejection Reasons
Common reasons for rejection include:
- Overly broad permissions — requesting access to all URLs when you only need specific ones
- Missing privacy policy — required if your extension collects any user data
- Misleading description — claiming features that do not exist
- Single-purpose policy violation — each extension should do one primary thing
Monetization Options
If you build something valuable, there are several ways to monetize:
- Freemium model — free core features with a paid upgrade. Use
chrome.storage.syncto track plan status, and verify with your server. - One-time purchase — the Chrome Web Store supports paid extensions, though this has become less common.
- Subscription via your own server — integrate with Stripe or Razorpay. The extension checks subscription status via API calls.
- Donations — add a "Buy me a coffee" link. This works surprisingly well for popular utilities.
- Affiliate links — if your extension relates to products or services, tasteful affiliate integration can generate revenue.
The freemium model tends to work best. Offer genuine value for free, and make the premium tier irresistible rather than making the free tier frustrating.
Going Further
Here are ideas to extend the bookmarker we built:
- Sync with a backend — store bookmarks in a database so they persist across devices, even when not signed into Chrome
- Keyboard shortcuts — define shortcuts in manifest.json under
"commands"so users can save pages without clicking - Import bookmarks — add a JSON/CSV import feature to complement the export
- Tagging suggestions — use the content script's metadata extraction to suggest tags automatically based on page keywords
- Offline support — cache saved bookmarks for offline access using the Cache API
Chrome extension development is one of those skills that compounds. Once you understand the architecture — manifest, popup, content scripts, service workers, message passing — you can build almost anything. Browser automation tools, productivity enhancers, developer utilities, accessibility helpers. The model is the same; only the logic changes.
I still remember the satisfaction of seeing my first extension's icon appear in Chrome's toolbar. That tiny icon represented something I built that lived right inside the browser, ready to help me every single day. There is something deeply rewarding about that. Go build something useful.
Advertisement
Advertisement
Ad Space
Priya Patel
Senior Tech Writer
Covers AI, machine learning, and emerging technologies. Previously at TechCrunch India.
Comments (0)
Leave a Comment
Related Articles
API Design Best Practices: REST, GraphQL, and the Patterns That Scale
A practical guide to designing APIs that last, covering REST conventions, GraphQL fundamentals, tRPC, authentication patterns, rate limiting, error handling, and testing tools with Node.js examples.
DSA Roadmap for Campus Placements: What Actually Matters in 2026
A realistic DSA preparation roadmap for campus placements in India, covering topic priorities, platform choices, language selection, company-wise patterns, and time management strategies.
Getting Started with AI in 2026: A Beginner's Complete Guide
Artificial Intelligence is transforming every industry. Learn the fundamentals of AI, popular tools, and how to begin your AI journey in 2026.