Chrome extensions remain one of the most accessible ways to ship useful developer tools. With Manifest V3 now fully enforced, here's a practical guide to building extensions the right way in 2026.
Why Manifest V3?
Manifest V3 replaced the old V2 format with a focus on security, privacy, and performance. The key changes include:
- Service workers instead of persistent background pages
- Declarative net request instead of webRequest blocking
- Tighter permissions with explicit host permissions
If you're starting a new extension today, V3 is the only option.
Project Structure
A typical Manifest V3 extension looks like this:
my-extension/
├── manifest.json
├── background.js # Service worker
├── content.js # Content script
├── popup/
│ ├── popup.html
│ └── popup.js
├── options/
│ ├── options.html
│ └── options.js
└── icons/
├── icon-16.png
├── icon-48.png
└── icon-128.png
The Manifest File
Here's a minimal but complete manifest.json:
{
"manifest_version": 3,
"name": "My Developer Tool",
"version": "1.0.0",
"description": "A helpful developer tool",
"permissions": ["storage", "activeTab"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"options_ui": {
"page": "options/options.html",
"open_in_tab": false
}
}
Understanding the Core Components
Content Scripts: Interact with Web Pages
Content scripts run in the context of web pages. They can read and modify the DOM but live in an isolated JavaScript environment.
// content.js
// Detect JSON responses and format them
const body = document.body;
const text = body?.innerText;
if (isJsonContent(text)) {
const parsed = JSON.parse(text);
renderFormattedJson(parsed, body);
}
function isJsonContent(text) {
try {
JSON.parse(text);
return document.contentType === 'application/json';
} catch {
return false;
}
}
function renderFormattedJson(data, container) {
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(data, null, 2);
pre.style.cssText = 'font-family: monospace; padding: 16px;';
container.innerHTML = '';
container.appendChild(pre);
}
Popup: Quick Actions UI
The popup appears when users click your extension icon. Keep it focused and fast.
<!-- popup/popup.html -->
<!DOCTYPE html>
<html>
<head>
<style>
body { width: 300px; padding: 16px; font-family: system-ui; }
.toggle { display: flex; justify-content: space-between; align-items: center; }
button { padding: 8px 16px; cursor: pointer; }
</style>
</head>
<body>
<h3>My Developer Tool</h3>
<div class="toggle">
<span>Enable formatting</span>
<button id="toggleBtn">ON</button>
</div>
<script src="popup.js"></script>
</body>
</html>
// popup/popup.js
const btn = document.getElementById('toggleBtn');
// Load saved state
chrome.storage.sync.get(['enabled'], (result) => {
const enabled = result.enabled !== false;
btn.textContent = enabled ? 'ON' : 'OFF';
});
// Toggle on click
btn.addEventListener('click', () => {
chrome.storage.sync.get(['enabled'], (result) => {
const newState = result.enabled === false;
chrome.storage.sync.set({ enabled: newState });
btn.textContent = newState ? 'ON' : 'OFF';
});
});
Options Page: User Preferences
The options page lets users configure your extension. It opens from chrome://extensions or right-clicking the extension icon.
// options/options.js
const themeSelect = document.getElementById('theme');
const indentInput = document.getElementById('indent');
// Load preferences
chrome.storage.sync.get(['theme', 'indent'], (result) => {
themeSelect.value = result.theme || 'dark';
indentInput.value = result.indent || 2;
});
// Save on change
document.getElementById('save').addEventListener('click', () => {
chrome.storage.sync.set({
theme: themeSelect.value,
indent: parseInt(indentInput.value, 10)
});
});
Service Worker: Background Logic
The service worker handles events, manages state, and coordinates between components.
// background.js
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
chrome.storage.sync.set({ enabled: true, theme: 'dark', indent: 2 });
}
});
// Listen for messages from content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_SETTINGS') {
chrome.storage.sync.get(['enabled', 'theme', 'indent'], (settings) => {
sendResponse(settings);
});
return true; // Keep channel open for async response
}
});
Permission Minimization Best Practices
One of V3's goals is reducing excessive permissions. Follow these rules:
1. Use activeTab instead of broad host permissions when possible
// Bad: Requests access to all sites on install
"host_permissions": ["<all_urls>"]
// Good: Only activates when user clicks the extension
"permissions": ["activeTab"]
2. Request optional permissions at runtime
// Request permissions only when needed
chrome.permissions.request(
{ origins: ['https://api.example.com/*'] },
(granted) => {
if (granted) {
fetchData();
}
}
);
3. Only declare what you use
Every permission you list appears in the Chrome Web Store listing. Users are more likely to install extensions with fewer permissions.
| Permission | Use When |
|---|---|
storage |
Saving user preferences |
activeTab |
Accessing the current tab on click |
tabs |
Reading tab URLs (consider if you really need this) |
alarms |
Scheduling periodic tasks |
Debugging and Testing
Loading Your Extension
- Open
chrome://extensions - Enable Developer mode (top right)
- Click Load unpacked and select your project folder
- Your extension appears in the toolbar
Debugging Tips
Service worker logs: Click "Inspect views: service worker" on the extensions page.
Content script logs: Open DevTools on any page where your content script runs. Logs appear in the regular console.
Popup debugging: Right-click your extension icon, click "Inspect popup". This opens DevTools for the popup context.
Testing Strategy
// Use a simple test runner for unit tests
// test/format.test.js
import { describe, it, expect } from 'vitest';
import { isJsonContent, formatJson } from '../src/utils.js';
describe('JSON detection', () => {
it('detects valid JSON strings', () => {
expect(isJsonContent('{"key": "value"}')).toBe(true);
});
it('rejects invalid JSON', () => {
expect(isJsonContent('not json')).toBe(false);
});
});
describe('JSON formatting', () => {
it('formats with custom indent', () => {
const input = '{"a":1}';
const result = formatJson(input, 4);
expect(result).toContain(' "a"');
});
});
For end-to-end testing, use Puppeteer with extension support:
const browser = await puppeteer.launch({
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`
]
});
Common Pitfalls
Service worker lifecycle: Unlike background pages, service workers terminate when idle. Don't store state in global variables; use
chrome.storageinstead.Content Security Policy: V3 disallows inline scripts in extension pages. Move all JavaScript to separate files.
Message passing: Always return
truefromonMessagelisteners if you callsendResponseasynchronously.
Wrapping Up
Manifest V3 enforces better security patterns that ultimately lead to better extensions. Start with minimal permissions, use the component model (content script, popup, options, service worker) to separate concerns, and test early.
If you want to see these patterns in action, check out JSON Formatter Pro - an open-source Chrome extension that auto-detects and beautifully formats JSON in your browser, built with all the Manifest V3 best practices covered in this guide.
Have questions about Chrome extension development? Drop them in the comments!
Top comments (0)