Build a Chrome extension from scratch with JavaScript
By Flavio Copes
Build a Chrome extension from scratch with JavaScript using Manifest V3, a popup, content script, service worker, storage, and shortcuts.
A Chrome extension is a small web application that can interact with the browser.
It uses the HTML, CSS, and JavaScript you already know. The unfamiliar part is that the code does not all run in one page.
An extension can have:
- a popup attached to the toolbar
- scripts that run inside web pages
- a service worker that handles browser events
- settings and data stored by Chrome
In this tutorial we’ll build Page Notes, an extension that lets us save a note for the page we are visiting.
The note is stored using the page URL. When we return to that page, the note comes back.
We’ll also add a keyboard shortcut that opens the popup.
Create the project
Create a new directory:
mkdir page-notes
cd page-notes
Add these files:
page-notes/
manifest.json
popup.html
popup.css
popup.js
content.js
service-worker.js
A Chrome extension does not need a build step. We can load this directory directly into Chrome.
Create the manifest
Every extension starts with manifest.json.
The manifest tells Chrome the extension’s name, which files it can run, and which browser features it needs.
{
"manifest_version": 3,
"name": "Page Notes",
"version": "1.0.0",
"description": "Save a private note for the current page",
"action": {
"default_popup": "popup.html",
"default_title": "Page Notes"
},
"background": {
"service_worker": "service-worker.js"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"]
}
],
"permissions": ["storage", "activeTab"],
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+Y",
"mac": "Command+Shift+Y"
},
"description": "Open Page Notes"
}
}
}
We are using Manifest V3, the current Chrome extension format.
Notice the permissions array. An extension should ask for the smallest set of permissions it needs.
This extension needs:
storageto save notesactiveTabto read information about the current tab after the person opens the extension
The content script match pattern allows content.js to run on normal HTTP and HTTPS pages.
Load the extension in Chrome
Open this URL in Chrome:
chrome://extensions
Turn on Developer mode, then click Load unpacked.
Choose the page-notes directory.
Chrome adds the extension to the list. Pin it from the extensions menu so its icon stays in the toolbar.
During development, return to chrome://extensions and click the reload icon after changing the manifest, service worker, or content script.
Build the popup
The popup is a tiny web page. Add this to popup.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>Page Notes</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<main>
<p id="page-title">Loading page...</p>
<textarea
id="note"
rows="8"
placeholder="Write a note about this page"
></textarea>
<div class="actions">
<span id="status" aria-live="polite"></span>
<button id="save" type="button">Save note</button>
</div>
</main>
<script src="popup.js"></script>
</body>
</html>
Chrome extension pages cannot use inline JavaScript by default. Put the code in a separate file, as we did with popup.js.
Add a little CSS:
:root {
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
body {
width: 340px;
margin: 0;
}
main {
padding: 16px;
}
#page-title {
margin-top: 0;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
textarea {
box-sizing: border-box;
width: 100%;
padding: 10px;
resize: vertical;
font: inherit;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
}
#status {
font-size: 0.85rem;
}
Click the toolbar icon. The popup now appears, although it cannot do anything yet.
Read the current tab
The popup needs the current page URL and title.
Add this to popup.js:
const titleElement = document.querySelector('#page-title')
const noteElement = document.querySelector('#note')
const saveButton = document.querySelector('#save')
const statusElement = document.querySelector('#status')
let currentUrl = ''
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
})
if (!tab?.url) {
titleElement.textContent = 'This page is not available'
noteElement.disabled = true
saveButton.disabled = true
} else {
currentUrl = tab.url
titleElement.textContent = tab.title || tab.url
}
chrome.tabs.query() returns an array because a query can match many tabs. Our filters ask for the active tab in the current window, so we take the first item.
Some internal Chrome pages do not expose a normal URL to extensions. We disable the form when that happens.
Save a note with chrome.storage
Do not use localStorage for shared extension data.
Each extension context has its own environment. chrome.storage is designed for extension data and is available to the popup, service worker, and content scripts.
Use an object whose keys are URLs:
const result = await chrome.storage.local.get('notes')
const notes = result.notes || {}
noteElement.value = notes[currentUrl] || ''
saveButton.addEventListener('click', async () => {
const latest = await chrome.storage.local.get('notes')
const nextNotes = latest.notes || {}
nextNotes[currentUrl] = noteElement.value.trim()
await chrome.storage.local.set({ notes: nextNotes })
statusElement.textContent = 'Saved'
setTimeout(() => {
statusElement.textContent = ''
}, 1200)
})
Add that below the tab query, but only run it when currentUrl exists:
if (currentUrl) {
const result = await chrome.storage.local.get('notes')
const notes = result.notes || {}
noteElement.value = notes[currentUrl] || ''
}
Then keep the click listener after it.
Save a note, close the popup, and open it again. The note remains.
Normalize URLs
Right now these URLs get different notes:
https://example.com/article
https://example.com/article#comments
The fragment usually points to a location inside the same page. We can remove it before using the URL as a key:
function noteKey(value) {
const url = new URL(value)
url.hash = ''
return url.toString()
}
Change the assignment:
currentUrl = noteKey(tab.url)
You could also remove tracking parameters. Be conservative, though. Query parameters sometimes identify genuinely different pages.
Add a content script
A popup can use Chrome APIs, but it cannot directly modify the web page.
A content script runs in the context of a matching page. It can read and change that page’s DOM.
Let’s briefly highlight the page when a saved note exists.
Add this to content.js:
chrome.runtime.onMessage.addListener((message) => {
if (message.type !== 'FLASH_NOTE_MARKER') {
return
}
const marker = document.createElement('div')
marker.textContent = 'You have a saved note for this page'
marker.style.cssText = `
position: fixed;
z-index: 2147483647;
right: 16px;
bottom: 16px;
padding: 12px 16px;
border-radius: 6px;
color: white;
background: #202124;
font: 14px system-ui, sans-serif;
`
document.body.append(marker)
setTimeout(() => marker.remove(), 2200)
})
The script listens for a message. It does nothing until another extension context sends one.
After saving a note in popup.js, send that message to the active tab:
if (tab.id) {
await chrome.tabs.sendMessage(tab.id, {
type: 'FLASH_NOTE_MARKER',
})
}
Reload the extension, then reload the web page. Content scripts are inserted when a matching page loads, so an already-open page does not automatically get the new script.
Open the popup and save. The message appears over the page.
Use the service worker
Manifest V3 extensions use a service worker for background tasks.
Unlike an old persistent background page, the worker can stop when it has nothing to do. Chrome starts it again when an event arrives.
This has an important consequence: do not treat global variables as permanent storage.
Use chrome.storage for data that must survive.
Add this to service-worker.js:
chrome.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason !== 'install') {
return
}
const result = await chrome.storage.local.get('notes')
if (!result.notes) {
await chrome.storage.local.set({ notes: {} })
}
})
This initializes the storage object on the first installation.
We do not actually need initialization because the popup already handles a missing value. Still, this shows the right place for one-time setup and migrations.
For example, version 2 of the extension might convert an old array of notes into the URL-keyed object.
How extension messaging works
Our extension now has three separate environments:
- the popup
- the content script
- the service worker
They communicate with messages.
Use chrome.tabs.sendMessage(tabId, message) when sending to a content script in a specific tab.
Use chrome.runtime.sendMessage(message) for communication with other extension contexts, commonly the service worker.
Each listener should check a type field. This keeps different message shapes easy to understand:
chrome.runtime.onMessage.addListener((message, sender) => {
if (message.type === 'NOTE_SAVED') {
console.log('Note saved from tab', sender.tab?.id)
}
})
If a listener needs to return an asynchronous response, return a promise where supported or follow the callback pattern described in the Chrome documentation. Do not assume that an ordinary async listener behaves the same in every Chrome version.
Debug each context
The popup, page, content script, and worker have different developer tools.
To debug the popup, open it, right-click inside it, and choose Inspect.
To debug the service worker, open chrome://extensions and click the service worker link on the extension card.
To debug the content script, open the normal page developer tools. In the Console’s context selector, choose the extension’s content-script context.
Common mistakes are:
- editing a file but forgetting to reload the extension
- reloading the extension but not the page that receives the content script
- trying to use DOM APIs inside the service worker
- trying to use
chrome.tabswithout the required permission - expecting a service worker global variable to survive
Package the extension
Before publishing:
- add extension icons in several sizes
- replace broad host access with narrower matches if possible
- remove logs and unused permissions
- test installation in a fresh Chrome profile
- explain every permission in the store listing
Create a ZIP containing the files themselves, with manifest.json at its root.
Do not put remotely hosted JavaScript in a Manifest V3 extension. The executable code must be included in the extension package.
You now have a real Chrome extension with a popup, content script, service worker, persistent storage, messaging, and a keyboard shortcut.
The main idea to remember is that an extension is not one long-running web page. It is a group of small contexts that wake up for different jobs and communicate explicitly.
Related posts about js: