Building Firefox extensions has become much more approachable. Here's a practical getting-started guide using the WebExtensions API.
Getting Started: Your First Extension
Minimum required files:
my-extension/ manifest.json popup.html popup.js manifest.json:
{ "manifest_version": 2, "name": "My Extension", "version": "1.0", "description": "Does something useful", "permissions": ["activeTab"], "browser_action": { "default_popup": "popup.html", "default_title": "My Extension", "default_icon": { "48": "icons/icon-48.png" } } } popup.html:
<!DOCTYPE html> <html> <head><meta charset="utf-8"></head> <body> <button id="doSomething">Do Something</button> <p id="result"></p> <script src="popup.js"></script> </body> </html> popup.js:
document.getElementById('doSomething').addEventListener('click', async () => { const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); document.getElementById('result').textContent = 'URL: ' + tab.url; }); Loading in Firefox for Development
- Navigate to
about:debugging#/runtime/this-firefox - Click "Load Temporary Add-on"
- Select the
manifest.jsonfile - Your extension appears in the list
The extension stays loaded until Firefox restarts. Changes to files reload when you click "Reload" on the extension.
Inspect the extension: In about:debugging, click "Inspect" next to your extension to open DevTools for the background page/popup.
Content Scripts
Content scripts run in the context of web pages:
// manifest.json { "content_scripts": [{ "matches": ["https://example.com/*"], "js": ["content.js"] }] } // content.js — runs on every matching page console.log('Page title:', document.title); // Inject a button const btn = document.createElement('button'); btn.textContent = 'My Button'; document.body.prepend(btn); btn.addEventListener('click', () => { // Communicate with background script browser.runtime.sendMessage({ type: 'button-clicked', title: document.title }); }); Background Scripts
Background scripts run persistently (or event-based in MV3):
// manifest.json { "background": { "scripts": ["background.js"] } } // background.js browser.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'button-clicked') { console.log('Button clicked on:', message.title); // Could save to storage, open a tab, make an API call, etc. } }); // Listen for tab changes browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { if (changeInfo.status === 'complete') { console.log('Tab completed loading:', tab.url); } }); Storage API
// Save data await browser.storage.local.set({ counter: 42, settings: { theme: 'dark' } }); // Read data const result = await browser.storage.local.get('counter'); console.log(result.counter); // 42 // Read multiple keys const data = await browser.storage.local.get(['counter', 'settings']); // Listen for changes browser.storage.onChanged.addListener((changes, area) => { for (const [key, { oldValue, newValue }] of Object.entries(changes)) { console.log(`${key}: ${oldValue} → ${newValue}`); } }); Permissions: Request What You Need
Common permissions and what they allow:
{ "permissions": [ "activeTab", // Access current tab (on user action only) "tabs", // Access all tabs, URLs, titles "storage", // browser.storage.local and .sync "history", // browser.history "bookmarks", // browser.bookmarks "notifications", // browser.notifications "webRequest", // Intercept network requests (MV2) "<all_urls>", // Content scripts on all URLs "https://api.example.com/*" // Specific origin access ] } Request the minimum permissions your extension needs. Users see what permissions are requested at install time. Fewer permissions = more trust.
Debugging
Background page DevTools: about:debugging → Inspect → Full DevTools for background context.
Content script debugging: Open DevTools on the page where your content script runs → Console → see your content script's logs.
Popup debugging: Right-click the extension icon → Inspect Popup → DevTools for the popup.
Common mistakes to debug:
- Content script messages not reaching background: check
browser.runtime.sendMessageandonMessagehandler - Storage not persisting: make sure you're using
storage.local(notstorage.sessionwhich is tab-scoped) - API permission denied: add the required permission to
manifest.json
Packaging for AMO Submission
# Create a zip of the extension cd my-extension zip -r ../my-extension.zip . -x '*.git*' -x 'node_modules/*' Submit at addons.mozilla.org → Developer Hub → Submit a New Add-on.
AMO requirements:
- No obfuscated code (all code must be readable)
- No remote code execution (no
eval()of remote strings) - Justification for each permission requested
- Privacy policy if you collect any user data
Review time: typically 1-7 days for unlisted (self-distribution), longer for listed extensions.
Moving to Manifest V3
Firefox supports MV3 while maintaining blocking webRequest. For new extensions, MV3 is the forward-compatible choice:
{ "manifest_version": 3, "action": { // Changed from browser_action "default_popup": "popup.html" }, "background": { "service_worker": "background.js" // Service worker instead of script } } In MV3, background scripts are service workers — they don't run persistently. Use chrome.storage for state that persists between activations.
The transition affects architecture but the core browser APIs remain the same.