DEV Community

Weather Clock Dash
Weather Clock Dash

Posted on

Building Firefox Extensions: WebExtensions API, MV3 vs MV2, and Publishing to AMO

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Declare content scripts in manifest.json:

{
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_end"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
// 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';
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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/*"]
Enter fullscreen mode Exit fullscreen mode

activeTab is preferred over <all_urls> — it grants access to the current tab only when the user explicitly invokes the extension.

Testing Your Extension

  1. Open about:debugging in Firefox
  2. Click "This Firefox" → "Load Temporary Add-on"
  3. 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
Enter fullscreen mode Exit fullscreen mode

Publishing to AMO

  1. Create a developer account at addons.mozilla.org
  2. Zip your extension (excluding node_modules, .git, etc.)
  3. 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)