Firefox extensions are built with WebExtensions — the same standard used by Chrome, Edge, and Safari (with minor differences). This guide walks through building a real extension from scratch, from manifest to publishing on AMO.
The Manifest
Every extension starts with manifest.json:
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "What it does in one sentence",
"permissions": ["activeTab", "storage"],
"background": {
"scripts": ["background.js"],
"type": "module"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png"
}
},
"icons": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
}
}
Firefox and MV3: Firefox supports MV3 but also retains full MV2 support. Unlike Chrome's MV3 (which removed the blocking webRequest API), Firefox's MV3 keeps it — which is why uBlock Origin works at full power on Firefox but not Chrome.
If you need the blocking webRequest API for content filtering, use MV2. For everything else, MV3 is the way forward.
The Background Script
The background script runs persistently (in MV2) or as a service worker (in MV3). It handles events, manages state, and coordinates between extension components.
// background.js (MV3 service worker)
// Handle toolbar button clicks
chrome.action.onClicked.addListener(async (tab) => {
// Toggle extension on/off for this tab
const result = await chrome.storage.local.get(['enabledTabs']);
const enabledTabs = result.enabledTabs || {};
enabledTabs[tab.id] = !enabledTabs[tab.id];
await chrome.storage.local.set({ enabledTabs });
// Notify the content script
chrome.tabs.sendMessage(tab.id, {
type: 'toggle',
enabled: enabledTabs[tab.id]
});
});
// Listen for messages from content scripts or popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'getState') {
chrome.storage.local.get(['enabledTabs'], result => {
sendResponse({ enabled: (result.enabledTabs || {})[sender.tab.id] });
});
return true; // keep message channel open for async response
}
});
Note: In MV3 service workers, state doesn't persist between activations. Use chrome.storage for anything that needs to survive.
Content Scripts
Content scripts run in the context of web pages. They can read and modify the DOM but run in an isolated world — they can't access the page's JavaScript variables.
// content.js
// Run immediately when injected
const init = async () => {
// Get state from background
const response = await chrome.runtime.sendMessage({ type: 'getState' });
if (response.enabled) applyStyles();
};
function applyStyles() {
const style = document.createElement('style');
style.id = 'my-extension-styles';
style.textContent = `
body { background: #1a1a2e !important; color: #e0e0e0 !important; }
a { color: #7fa8d4 !important; }
`;
document.head.appendChild(style);
}
function removeStyles() {
document.getElementById('my-extension-styles')?.remove();
}
// Listen for toggle messages
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'toggle') {
message.enabled ? applyStyles() : removeStyles();
}
});
init();
Declare content scripts in manifest.json:
{
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_end"
}
]
}
The Popup
The popup is a standard HTML/CSS/JS page that appears when the toolbar button is clicked:
<!-- popup.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<h2>My Extension</h2>
<label class="toggle">
<input type="checkbox" id="enabled">
<span class="slider"></span>
Enable on this page
</label>
<p id="status"></p>
</div>
<script src="popup.js"></script>
</body>
</html>
// popup.js
document.addEventListener('DOMContentLoaded', async () => {
const checkbox = document.getElementById('enabled');
const status = document.getElementById('status');
// Get current tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
// Get current state
const response = await chrome.runtime.sendMessage({ type: 'getState' });
checkbox.checked = response.enabled;
// Toggle on change
checkbox.addEventListener('change', () => {
chrome.runtime.sendMessage({
type: 'toggle',
tabId: tab.id,
enabled: checkbox.checked
});
status.textContent = checkbox.checked ? 'Enabled' : 'Disabled';
});
});
Storage
Use chrome.storage.local for extension data (synced across devices with chrome.storage.sync):
// Save
await chrome.storage.local.set({
settings: { darkMode: true, fontSize: 16 }
});
// Read
const { settings } = await chrome.storage.local.get('settings');
console.log(settings.darkMode); // true
// Remove
await chrome.storage.local.remove('settings');
// Listen for changes
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.settings) {
console.log('Settings changed:', changes.settings.newValue);
}
});
chrome.storage.sync syncs across the user's Firefox account (up to 100KB, individual item max 8KB).
Permissions Best Practices
Request only what you need. AMO reviewers scrutinize permissions, and users are wary of extensions requesting broad access.
// Bad: requesting more than needed
"permissions": ["<all_urls>", "tabs", "history", "bookmarks"]
// Good: minimum viable permissions
"permissions": ["activeTab", "storage"]
// Use host_permissions for site-specific access
"host_permissions": ["https://api.github.com/*"]
activeTab is preferred over <all_urls> — it grants access to the current tab only when the user explicitly invokes the extension.
Testing Your Extension
- Open
about:debuggingin Firefox - Click "This Firefox" → "Load Temporary Add-on"
- Select your
manifest.json
The extension loads without signing. Changes to non-background files apply on reload; background script changes require removing and re-adding the extension.
For faster iteration, use web-ext:
npm install -g web-ext
web-ext run # loads extension and auto-reloads on file changes
Publishing to AMO
- Create a developer account at addons.mozilla.org
- Zip your extension (excluding
node_modules,.git, etc.) - Submit at AMO → Developer Hub → Submit a New Add-on
AMO requires unobfuscated source code. If you use a bundler, submit the source separately. Obfuscated or minified-beyond-readability code will be rejected.
The review process takes 1-7 days for new submissions, faster for updates. Reviewers check:
- All requested permissions are used and justified
- No data collection without disclosure
- No obfuscated code
- Extension does what it claims
Key Differences from Chrome Extensions
| Feature | Firefox | Chrome |
|---|---|---|
| MV2 support | Yes, indefinitely | Being deprecated |
blocking webRequest |
Yes (both MV2 and MV3) | MV3 removed it |
| Review process | Human review on AMO | Automated + occasional human |
| Extension store | AMO (addons.mozilla.org) | Chrome Web Store |
browser namespace |
Yes (browser.*) |
No (chrome.* only) |
| Promise-based APIs | Yes | MV3 added, MV2 callbacks only |
Firefox supports both browser.* (Promise-based) and chrome.* (callback-based) namespaces. Use browser.* for cleaner code in Firefox-only extensions; use chrome.* with the webextension-polyfill library for cross-browser extensions.
Tab Manager and NightOwl
If you're looking for production Firefox extension examples, check out:
- Tab Manager & Session Saver — manages tab groups and saves sessions across restarts. Available at addons.mozilla.org
- NightOwl — dark mode for any website with per-site settings
Both are built with WebExtensions and demonstrate real-world patterns for session management, content script injection, and extension popup UI.
Top comments (0)