How to Build a Minimal Chrome Extension in 30 Minutes (Manifest V3)
You don’t need a framework, a build step, or a backend to make a genuinely useful Chrome extension. A popup, a manifest, and a few dozen lines of JavaScript are enough to build something you’ll use every day.
This guide walks through a minimal Manifest V3 extension from scratch, end to end - the exact same shape as SaveBuddy. By the end you’ll have a working “save a link” extension you can load in Chrome.
What you’re building
A toolbar popup that lets you:
- Save a URL with an optional title.
- Save the current tab in one click.
- See a list of your saved links with favicons.
- Edit and delete links.
All data lives in the extension’s localStorage. No servers, no accounts.
Step 0: The folder structure
Create a folder called my-extension with three files:
my-extension/
├── manifest.json
├── index.html
├── index.js
└── style.css
Step 1: manifest.json
The manifest is the extension’s identity card. For Manifest V3:
{
"manifest_version": 3,
"version": "1.0",
"name": "My Link Keeper",
"description": "Save and manage links from a popup.",
"action": {
"default_popup": "index.html"
},
"permissions": ["tabs"],
"commands": {
"_execute_action": {
"suggested_key": { "default": "Alt+K" }
}
}
}
Notes:
action.default_popuppoints to your popup HTML.permissions: ["tabs"]lets you read the active tab’s URL and title.commandswires up a keyboard shortcut to open the popup.
Step 2: index.html - the popup
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>My Links</h1>
<input id="url" type="text" placeholder="Enter URL">
<input id="title" type="text" placeholder="Optional title">
<button id="save">SAVE LINK</button>
<button id="save-tab">SAVE TAB</button>
<div id="links"></div>
<button id="delete-all">DELETE ALL</button>
<script src="index.js"></script>
</body>
</html>
Step 3: index.js - the logic
Every file has a job:
const STORAGE_KEY = 'myLinks';
function getLinks() {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
}
function saveLinks(links) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(links));
}
function render() {
const container = document.getElementById('links');
container.innerHTML = '';
getLinks().forEach((link, i) => {
const div = document.createElement('div');
const favicon = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(link.url)}`;
div.innerHTML =
`<img src="${favicon}" alt="">
<div>
<strong>${escapeHtml(link.title)}</strong>
<span>${escapeHtml(link.url)}</span>
</div>
<button data-edit="${i}">Edit</button>
<button data-copy="${i}">Copy</button>
<button data-delete="${i}">Delete</button>`;
container.appendChild(div);
});
}
A few important details:
escapeHtml- never render user input without escaping; this is a real XSS vector.- Favicons via Google’s service - a small network request per link. Fine for a personal tool.
localStorageas a JSON array - simple, and it survives popup reopenings.
Step 4: Load it in Chrome
- Open
chrome://extensions/. - Toggle Developer mode (top-right).
- Click Load unpacked and select
my-extension/. - Pin it to the toolbar. Open it with the shortcut (
Alt+K) or the icon.
Step 5: Things people forget
Error handling
An empty URL should show an error, not silently save junk:
document.getElementById('save').addEventListener('click', () => {
const url = document.getElementById('url').value.trim();
if (!url) { alert('Please enter a URL'); return; }
const links = getLinks();
links.push({ id: Date.now(), url, title: title.value || 'Untitled Link' });
saveLinks(links);
render();
});
Saving the current tab
The tabs permission gives you the active tab:
document.getElementById('save-tab').addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const links = getLinks();
links.push({ id: Date.now(), url: tab.url, title: tab.title || 'Untitled Link' });
saveLinks(links);
render();
});
Don’t re-invent identity
Use Date.now() (or crypto.randomUUID()) as a stable id so edit and delete targets don’t break when the list re-orders.
What’s next
Once the basics work, the same 30-minute foundation can grow into:
- Import/export (JSON file).
- A search box over saved links.
- Better storage via
chrome.storage.local(survives browser restarts more reliably). - Publish it on the Chrome Web Store.
The real-world version
This walkthrough is a simplified version of what SaveBuddy actually ships - same popup shape, same localStorage approach, same single tabs permission, MIT licensed. Read the real source in the GitHub repo, or just install it:
Build something small, ship it, and iterate - that’s the whole trick. Happy hacking!