Build a Chrome Extension: Step-by-Step (No Prior Experience Needed)
Learn to build Chrome extensions with Manifest V3. Popups, content scripts, service workers, storage, and a complete bookmark manager project.
Chrome extensions are one of the most satisfying things you can build as a developer. You write a bit of JavaScript, and suddenly your browser does something it couldn't before. No server needed. No deployment pipeline. Just HTML, CSS, JS, and a manifest file.
And the barrier to entry is low. If you can write basic JavaScript, you can build a Chrome extension today. Not tomorrow, not after another course. Today.
By the end of this tutorial, you'll understand every piece of a Chrome extension and you'll have built a functional bookmark manager with tags, search, and folder organization.
How Chrome Extensions Work
A Chrome extension is a zip file containing web files (HTML, CSS, JS, images) plus a manifest.json that tells Chrome what the extension does and what permissions it needs.
There are three main execution contexts:
- Popup -- The little window that appears when you click the extension icon. Standard HTML page.
- Content Scripts -- JavaScript that runs inside web pages. Can read and modify the DOM of any page you visit.
- Background Service Worker -- Runs independently. Handles events, manages state, coordinates between components. No DOM access.
Project Setup
Create a folder for your extension:
my-extension/
manifest.json
popup.html
popup.css
popup.js
background.js
content.js
icons/
icon16.png
icon48.png
icon128.png
The Manifest: manifest.json (V3)
Every extension starts here. Manifest V3 is the current standard -- V2 is deprecated and Chrome is phasing it out.
{
"manifest_version": 3,
"name": "My First Extension",
"version": "1.0",
"description": "A simple Chrome extension",
"permissions": ["storage", "tabs", "activeTab"],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
]
}
Key fields:
manifest_version: 3-- Required. Use 3, not 2.permissions-- What APIs your extension can access. Only request what you need.action-- Defines the toolbar icon and popup.background.service_worker-- Your background script (replacesbackground.pagefrom V2).content_scripts-- Scripts injected into matching pages.
Loading Your Extension for Development
- Open
chrome://extensions - Enable "Developer mode" (top right toggle)
- Click "Load unpacked"
- Select your extension folder
Building the Popup
The popup is just an HTML page. It loads when the user clicks your extension icon.
<!-- popup.html -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<h1>Quick Bookmark</h1>
<button id="saveBtn">Save This Page</button>
<div id="status"></div>
<div id="bookmarks"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
/ popup.css /
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 350px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
padding: 16px;
}
.container {
display: flex;
flex-direction: column;
gap: 12px;
}
h1 {
font-size: 18px;
color: #1a1a1a;
}
button {
padding: 10px 16px;
background: #4285f4;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #3367d6;
}
.bookmark-item {
padding: 8px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 13px;
}
.bookmark-item a {
color: #1a73e8;
text-decoration: none;
}
// popup.js
document.addEventListener('DOMContentLoaded', () => {
const saveBtn = document.getElementById('saveBtn');
const status = document.getElementById('status');
const bookmarksList = document.getElementById('bookmarks');
// Save current tab
saveBtn.addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const bookmark = {
title: tab.title,
url: tab.url,
savedAt: new Date().toISOString()
};
// Get existing bookmarks
const { bookmarks = [] } = await chrome.storage.local.get('bookmarks');
bookmarks.unshift(bookmark);
await chrome.storage.local.set({ bookmarks });
status.textContent = 'Saved!';
setTimeout(() => { status.textContent = ''; }, 2000);
renderBookmarks(bookmarks);
});
// Load bookmarks on popup open
loadBookmarks();
async function loadBookmarks() {
const { bookmarks = [] } = await chrome.storage.local.get('bookmarks');
renderBookmarks(bookmarks);
}
function renderBookmarks(bookmarks) {
bookmarksList.innerHTML = bookmarks.slice(0, 10).map(b =>
<div class="bookmark-item">
<a href="${b.url}" target="_blank">${b.title}</a>
</div>
).join('');
}
});
Note: You can't use inline scripts in Chrome extensions (Content Security Policy). Always use separate .js files and link them with .
Content Scripts: Modifying Web Pages
Content scripts run in the context of web pages. They can read and modify the DOM but can't access the page's JavaScript variables (they run in an isolated world).
// content.js
// Highlight all links on the page
function highlightLinks() {
const links = document.querySelectorAll('a');
links.forEach(link => {
link.style.border = '2px solid #4285f4';
link.style.borderRadius = '3px';
});
}
// Run when the page loads
highlightLinks();
// Listen for messages from popup or background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'countLinks') {
const count = document.querySelectorAll('a').length;
sendResponse({ count });
}
return true; // Required for async sendResponse
});
Background Service Workers
The service worker runs independently of any page. It handles events, alarms, and coordinates between parts of your extension.
// background.js
// Runs when the extension is installed or updated
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
console.log('Extension installed!');
chrome.storage.local.set({ bookmarks: [] });
}
});
// Listen for messages from popup or content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'getTabInfo') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
sendResponse({ tab: tabs[0] });
});
return true; // Keep the message channel open for async response
}
});
// Context menu (right-click menu)
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'saveBookmark',
title: 'Save to Quick Bookmark',
contexts: ['page', 'link']
});
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'saveBookmark') {
const bookmark = {
title: tab.title,
url: info.linkUrl || tab.url,
savedAt: new Date().toISOString()
};
saveBookmark(bookmark);
}
});
async function saveBookmark(bookmark) {
const { bookmarks = [] } = await chrome.storage.local.get('bookmarks');
bookmarks.unshift(bookmark);
await chrome.storage.local.set({ bookmarks });
}
Important V3 change: service workers are ephemeral. They shut down after ~30 seconds of inactivity and restart when needed. Don't store state in global variables -- use chrome.storage instead.
The Storage API
Chrome provides two storage areas:
// storage.local -- stores data locally (up to 10MB)
await chrome.storage.local.set({ key: 'value', count: 42 });
const result = await chrome.storage.local.get(['key', 'count']);
console.log(result.key); // 'value'
// storage.sync -- syncs across devices (100KB limit, smaller per-item limits)
await chrome.storage.sync.set({ settings: { theme: 'dark' } });
const { settings } = await chrome.storage.sync.get('settings');
// Listen for changes
chrome.storage.onChanged.addListener((changes, areaName) => {
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
console.log(${key} changed from, oldValue, 'to', newValue);
}
});
Use storage.local for bookmarks, history, cached data. Use storage.sync for user preferences and settings.
Messaging Between Components
Components communicate via message passing:
// From popup to background
chrome.runtime.sendMessage({ action: 'doSomething', data: 123 }, (response) => {
console.log('Got response:', response);
});
// From popup to content script (need the tab ID)
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
chrome.tabs.sendMessage(tab.id, { action: 'highlight' }, (response) => {
console.log('Content script responded:', response);
});
// In the receiving script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'doSomething') {
// Process the message
sendResponse({ result: 'done' });
}
return true; // IMPORTANT: return true if sendResponse is called asynchronously
});
The return true in the listener is critical if your response is async. Without it, the message channel closes before you can respond.
Building the Bookmark Manager Extension
Let's expand our simple bookmarks into a proper extension with tags, search, and folders.
Updated popup.html:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<div class="header">
<h1>Bookmark Manager</h1>
<input type="text" id="search" placeholder="Search bookmarks...">
</div>
<div class="actions">
<button id="saveBtn">Save Current Page</button>
<select id="folderSelect">
<option value="default">Default</option>
<option value="work">Work</option>
<option value="personal">Personal</option>
<option value="reading">Read Later</option>
</select>
</div>
<div id="tagInput" style="display:none;">
<input type="text" id="tags" placeholder="Tags (comma-separated)">
<button id="confirmSave">Confirm</button>
</div>
<div id="bookmarks"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
Updated popup.js:
document.addEventListener('DOMContentLoaded', () => {
const saveBtn = document.getElementById('saveBtn');
const confirmSave = document.getElementById('confirmSave');
const tagInputDiv = document.getElementById('tagInput');
const tagsInput = document.getElementById('tags');
const folderSelect = document.getElementById('folderSelect');
const searchInput = document.getElementById('search');
const bookmarksList = document.getElementById('bookmarks');
let currentTab = null;
// Save button shows tag input
saveBtn.addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentTab = tab;
tagInputDiv.style.display = 'block';
tagsInput.focus();
});
// Confirm save with tags and folder
confirmSave.addEventListener('click', async () => {
if (!currentTab) return;
const bookmark = {
id: Date.now().toString(),
title: currentTab.title,
url: currentTab.url,
folder: folderSelect.value,
tags: tagsInput.value.split(',').map(t => t.trim()).filter(Boolean),
savedAt: new Date().toISOString()
};
const { bookmarks = [] } = await chrome.storage.local.get('bookmarks');
// Prevent duplicates
if (bookmarks.some(b => b.url === bookmark.url)) {
showStatus('Already saved!');
return;
}
bookmarks.unshift(bookmark);
await chrome.storage.local.set({ bookmarks });
tagInputDiv.style.display = 'none';
tagsInput.value = '';
showStatus('Saved!');
renderBookmarks(bookmarks);
});
// Search
searchInput.addEventListener('input', async () => {
const query = searchInput.value.toLowerCase();
const { bookmarks = [] } = await chrome.storage.local.get('bookmarks');
const filtered = bookmarks.filter(b =>
b.title.toLowerCase().includes(query) ||
b.url.toLowerCase().includes(query) ||
b.tags.some(t => t.toLowerCase().includes(query)) ||
b.folder.toLowerCase().includes(query)
);
renderBookmarks(filtered);
});
// Delete bookmark
bookmarksList.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-btn')) {
const id = e.target.dataset.id;
const { bookmarks = [] } = await chrome.storage.local.get('bookmarks');
const updated = bookmarks.filter(b => b.id !== id);
await chrome.storage.local.set({ bookmarks: updated });
renderBookmarks(updated);
}
});
function renderBookmarks(bookmarks) {
if (bookmarks.length === 0) {
bookmarksList.innerHTML = '<p class="empty">No bookmarks yet.</p>';
return;
}
bookmarksList.innerHTML = bookmarks.slice(0, 20).map(b =>
<div class="bookmark-item">
<div class="bookmark-header">
<a href="${b.url}" target="_blank">${b.title}</a>
<button class="delete-btn" data-id="${b.id}">x</button>
</div>
<div class="bookmark-meta">
<span class="folder">${b.folder}</span>
${b.tags.map(t => <span class="tag">${t}</span>).join('')}
</div>
</div>
).join('');
}
function showStatus(text) {
const el = document.createElement('div');
el.className = 'status';
el.textContent = text;
document.querySelector('.container').appendChild(el);
setTimeout(() => el.remove(), 2000);
}
// Load on open
(async () => {
const { bookmarks = [] } = await chrome.storage.local.get('bookmarks');
renderBookmarks(bookmarks);
})();
});
Publishing to the Chrome Web Store
When you're ready to publish:
- Create a zip of your extension folder (not the folder itself -- the contents)
- Go to the Chrome Developer Dashboard
- Pay the one-time $5 registration fee
- Click "New Item" and upload your zip
- Fill in the listing: description, screenshots, category
- Submit for review (takes 1-3 days usually)
- Request minimal permissions. If you don't need
, don't ask for it - Write a clear privacy policy if you collect any data
- Include a clear description of what the extension does
- Make sure your extension actually works (they test it)
Common Mistakes
Using Manifest V2. V2 is deprecated. New submissions must use V3, and existing V2 extensions are being phased out. Start with V3. Storing state in service worker global variables. The service worker shuts down after inactivity. Any global variables are lost. Usechrome.storage for persistence.
Forgetting return true in message listeners. If your listener calls sendResponse asynchronously, you must return true from the listener. Otherwise the message port closes immediately.
Not handling the popup lifecycle. The popup is destroyed every time it closes. It reloads from scratch each time. Load state from storage in DOMContentLoaded.
Inline scripts in HTML. Chrome's CSP blocks inline tags and onclick handlers. Always use separate JS files and addEventListener.
What's Next
You've built a full Chrome extension with popup UI, storage, search, and context menus. From here, explore the Alarms API for periodic tasks, the Notifications API for user alerts, and the Side Panel API for persistent UI.
Chrome extensions are also a great portfolio piece. Employers love seeing shipped browser extensions because it shows you can build and ship a complete product.
Build more interactive projects at CodeUp to sharpen your JavaScript skills.