What Are Browser Extensions and Why Build Cross-Browser?
Browser extensions are small software programs that customize the browsing experience. They operate within the browser's security sandbox, accessing web page content and browser functionality through well-defined APIs. Unlike web applications, extensions can interact more deeply with the browser UI, intercept network requests, and store data locally.
The business case for cross-browser development is compelling. By targeting multiple browsers, you multiply your potential user base without proportionally increasing development effort. According to browser market share data, Chrome dominates with approximately 65% of users, but Firefox, Edge, and Safari collectively represent a significant audience. For consumer-facing extensions, ignoring these users means leaving substantial reach on the table.
The WebExtensions API, originally developed by Mozilla for Firefox and later adopted by Google for Chrome (with modifications), forms the foundation for this cross-browser compatibility. This API provides a standardized way to access browser functionality, though implementations still vary across browsers in subtle but important ways.
For organizations looking to extend their digital presence, our web development services include browser extension development as a powerful tool for user engagement and productivity enhancement.
The Evolution from Manifest V2 to Manifest V3
The browser extension platform underwent a significant transformation with the introduction of Manifest V3 (MV3). Google mandated the transition from Manifest V2 (MV2) for Chrome extensions, with MV2 extensions no longer accepted in the Chrome Web Store as of 2023. Firefox has also adopted MV3 while maintaining some MV2 support for compatibility.
Key changes in MV3 include:
-
Background service workers replace background pages: Persistent background pages that could run indefinitely are replaced with ephemeral service workers that activate on events and terminate when idle. This improves security and reduces resource consumption but requires rethinking state management. Global variables no longer persist between invocations, so all state must be stored in
chrome.storageor external databases. -
Declarative Net Request for content blocking: Extensions can no longer use the blocking webRequest API for content filtering. Instead, they must use the declarativeNetRequest API, which declares rules upfront rather than intercepting and modifying requests in real-time. This shift improves security by preventing extensions from modifying network requests on the fly.
-
Remote code execution restrictions: Extensions cannot execute remote code; all JavaScript must be bundled with the extension package. This prevents malicious extensions from loading external scripts but requires updates to be submitted for code changes. Dynamic code evaluation through
eval()andnew Function()is also restricted. -
Promise-based APIs: Many extension APIs now return Promises instead of using callbacks, enabling cleaner asynchronous code patterns. While Chromium browsers have been adding Promise support, the WebExtension browser API polyfill remains the most reliable way to ensure consistent cross-browser Promise behavior.
Choose the right framework for your project
WXT
The leading framework in 2025. Built on Vite with excellent cross-browser support, auto-imports, and framework-agnostic architecture.
Plasmo
Battery-included framework with React support. Uses Parcel bundler but has community concerns about maintenance pace.
CRXJS
Lightweight Vite plugin focused on Chrome extension builds. Minimal configuration but requires more manual setup.
Understanding the Manifest File
At the heart of every extension is the manifest.json file (or manifest.config.ts in WXT). This file declares the extension's capabilities, permissions, and components to the browser. The manifest is the contract between your extension and the browser--incorrect configuration means your extension won't load.
Core Manifest Structure
The manifest file uses JSON format and must include specific required fields. Beyond the basics, you declare the components your extension uses--background service workers, content scripts, popup pages, and options pages--along with the permissions needed to access browser APIs.
{
"manifest_version": 3,
"name": "My Cross-Browser Extension",
"version": "1.0.0",
"description": "A browser extension that works across multiple browsers",
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"action": {
"default_popup": "popup.html",
"default_icon": "icons/icon-48.png"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["storage", "activeTab", "scripting"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
}
```n ]\n
### Permissions and Their Implications
Permissions control what your extension can access. Requesting excessive permissions raises security concerns and can affect approval in browser stores. Best practices include requesting only permissions the extension actually needs, using optional permissions that users must explicitly grant, explaining permission needs in the extension's store listing, and testing with minimal permissions during development.
- **storage**: Access local storage for extension data. This permission enables saving user preferences, cached data, and extension state across browser sessions. It's one of the most commonly needed permissions.
- **activeTab**: Access the current tab when user invokes extension. This is the preferred alternative to `<all_urls>` host permissions because it only grants access when the user actively interacts with your extension.
- **scripting**: Execute scripts in pages. Required for programmatic content script injection. Combined with activeTab, this provides a secure pattern for modifying page content.
- **tabs**: Access browser tab information. This permission grants read access to tab titles, URLs, and favicons across all tabs--not just active ones. Consider whether you truly need this or if activeTab suffices.
- **cookies**: Read and modify cookies. This powerful permission should be used sparingly. Many use cases can be accomplished with storage permissions instead.
1{2 "manifest_version": 3,3 "name": "My Cross-Browser Extension",4 "version": "1.0.0",5 "description": "A browser extension that works across multiple browsers",6 "icons": {7 "16": "icons/icon-16.png",8 "48": "icons/icon-48.png",9 "128": "icons/icon-128.png"10 },11 "action": {12 "default_popup": "popup.html",13 "default_icon": "icons/icon-48.png"14 },15 "background": {16 "service_worker": "background.js",17 "type": "module"18 },19 "permissions": ["storage", "activeTab", "scripting"],20 "content_scripts": [21 {22 "matches": ["<all_urls>"],23 "js": ["content.js"]24 }25 ]26}Building Core Extension Components
Background Service Workers
In Manifest V3, background scripts run as service workers rather than persistent pages. This architectural change means your background code executes in response to events and terminates when idle. The key implications include no persistent state (any state that must persist across browser sessions must be stored in chrome.storage rather than global variables), event-driven execution (the service worker activates when events like runtime.onInstalled or alarms.onAlarm occur), and no DOM access (service workers cannot access the DOM directly; they communicate with content scripts via message passing).
// background.js - Example service worker using WebExtension polyfill
import browser from 'webextension-polyfill';
// Initialize extension on install
browser.runtime.onInstalled.addListener(() => {
browser.storage.local.set({ enabled: true, count: 0 });
console.log('Extension installed');
});
// Handle toolbar icon click
browser.action.onClicked.addListener(async (tab) => {
const { enabled } = await browser.storage.local.get('enabled');
if (enabled) {
// Inject content script into the active tab
browser.scripting.executeScript({
target: { tabId: tab.id },
files: ['content.js']
});
}
});
// Handle messages from content scripts
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'getPageData') {
// Respond with data from storage
browser.storage.local.get('count').then(result => {
sendResponse({ count: result.count });
});
return true; // Keep message channel open for async response
}
});
Content Scripts
Content scripts run in the context of web pages, allowing extensions to read and modify page content. They have a separate JavaScript execution environment from the page, though they can communicate through message passing. Content scripts can access and modify the DOM of web pages, read page metadata and attributes, communicate with background scripts and popup pages, and use a subset of Chrome APIs (storage, messaging, etc.). However, content scripts operate in an isolated world in Chrome, meaning they cannot access JavaScript variables or functions defined by the page. In Firefox, the isolation model differs slightly.
// content.js - Example content script
import browser from 'webextension-polyfill';
// Read page data and send to background script
const pageData = {
url: window.location.href,
title: document.title,
textContent: document.body.innerText.substring(0, 500)
};
// Send data to background script
browser.runtime.sendMessage({ type: 'pageData', data: pageData });
// Listen for messages from background script
browser.runtime.onMessage.addListener((message) => {
if (message.type === 'highlight') {
// Highlight elements matching the selector
document.querySelectorAll(message.selector).forEach(el => {
el.style.backgroundColor = message.color;
});
}
});
Popup and Options Pages
The popup is a transient HTML page that appears when users click the extension's toolbar icon. Options pages provide persistent settings interfaces. Both are standard web pages with access to extension APIs. In WXT, popup and options pages are created as Vue or React components (depending on your framework choice) with automatic routing configuration.
<!-- popup/App.vue - Example popup component -->
<template>
<div class="popup">
<h2>My Extension</h2>
<p>Enabled: {{ enabled ? 'Yes' : 'No' }}</p>
<button @click="toggleEnabled">{{ enabled ? 'Disable' : 'Enable' }}</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import browser from 'webextension-polyfill';
const enabled = ref(true);
onMounted(async () => {
const result = await browser.storage.local.get('enabled');
enabled.value = result.enabled ?? true;
});
async function toggleEnabled() {
enabled.value = !enabled.value;
await browser.storage.local.set({ enabled: enabled.value });
}
</script>
<style scoped>
.popup { padding: 16px; min-width: 200px; }
button { margin-top: 12px; }
</style>
1// background.js - Example service worker2import browser from 'webextension-polyfill';3 4browser.runtime.onInstalled.addListener(() => {5 // Initialize extension state6 browser.storage.local.set({ enabled: true });7 console.log('Extension installed');8});9 10browser.action.onClicked.addListener(async (tab) => {11 // Handle toolbar icon click12 const { enabled } = await browser.storage.local.get('enabled');13 if (enabled) {14 browser.scripting.executeScript({15 target: { tabId: tab.id },16 files: ['content.js']17 });18 }19});Achieving Cross-Browser Compatibility
The primary challenge in cross-browser extension development is the inconsistent implementation of the WebExtensions API across browsers. Even with a common API, differences exist in namespace usage, function availability, and behavior.
API Namespace Differences
Firefox and Safari primarily use the browser.* namespace for extension APIs, which returns Promises for asynchronous operations. Chromium-based browsers (Chrome, Edge, Opera) use the chrome.* namespace with callback-based APIs. While Chromium browsers have been adding Promise support, full compatibility isn't guaranteed. The recommended approach is to use the WebExtension browser API Polyfill maintained by Mozilla.
The WebExtension Browser API Polyfill
Mozilla maintains a polyfill that provides a consistent Promise-based API across all browsers. This polyfill maps browser.* calls to the appropriate browser-specific APIs.
// Install via npm
import browser from 'webextension-polyfill';
// Use browser.* API consistently across all browsers
browser.runtime.onInstalled.addListener(() => {
console.log('Extension installed');
});
await browser.storage.local.set({ key: 'value' });
const result = await browser.storage.local.get('key');
Handling Browser-Specific Behavior
Beyond API differences, browsers exhibit different behaviors in certain scenarios. Content script injection, storage synchronization, and icon display all have browser-specific quirks.
Content script injection timing: Firefox injects content scripts immediately when the page loads, while Chrome may delay injection for complex pages. Use the runAt property in content script configuration to control injection timing.
Icon display differences: Safari scales icons differently than Chromium browsers. Always provide icons at multiple sizes (16, 32, 48, 128 pixels) and test on each target browser.
Storage sync limitations: Firefox's storage.sync implementation has stricter quotas than Chrome. If using sync storage, implement fallback to local storage when quotas are exceeded.
Cookie API variations: The cookies API has subtle differences in how it handles cookie attributes across browsers. Test cookie creation and retrieval on each target.
Recommended practices include testing on all target browsers throughout development, using feature detection rather than browser detection, abstracting browser-specific code into dedicated modules, and maintaining browser-specific configuration options.
Modern Framework Development with WXT
WXT streamlines cross-browser extension development through intelligent defaults, auto-configuration, and built-in compatibility handling. Built on Vite, WXT provides excellent developer experience with features like auto-imports, file-based routing, and seamless cross-browser compatibility.
Project Structure and Conventions
WXT uses a file-based convention for defining extension components. Placing files in specific directories automatically configures the manifest.
my-extension/
├── entrypoints/
│ ├── background.ts # Background service worker
│ ├── popup/
│ │ └── App.vue # Popup UI component
│ └── options/
│ └── App.vue # Options page component
├── components/
│ └── MyComponent.vue # Shared components
├── public/
│ └── icons/ # Static assets
└── wxt.config.ts # WXT configuration
Built-in Cross-Browser Support
WXT handles many cross-browser concerns automatically. The framework generates browser-specific manifest files, handles content script injection configuration across browsers, resolves and optimizes icons for each browser's requirements, and packages builds for different store formats.
// wxt.config.ts - WXT configuration
export default defineConfig({
// Target browsers for compatibility
targetBrowser: 'chromium',
// Enable auto-import for extension APIs
autoImport: {
entries: ['runtime', 'storage', 'scripting']
},
// Configure content scripts
contentScripts: {
defaultMatches: ['<all_urls>'],
defaultRunAt: 'document_idle'
}
});
Development Workflow
WXT's development server provides hot module replacement for popup and options pages, dramatically improving iteration speed. Content script changes typically require a page refresh, but the overall experience is streamlined.
# Create a new extension project
npm create wxt@latest my-extension
cd my-extension
# Start development server with hot reload
npm run dev
# Build for production
npm run build
# Build for specific browsers
npm run build:firefox
npm run build:safari
The development server watches for file changes and automatically reloads the extension. Load the unpacked extension from the .output/chromium directory (or equivalent for your target browser) into your browser's extensions management page to test.
| Store | Registration Fee | Review Time | Key Requirements |
|---|---|---|---|
| Chrome Web Store | $5 one-time | Hours to days | Detailed description, screenshots, fees |
| Firefox Add-ons | Free | Hours to days | Source code for some permissions |
| Microsoft Edge | Free | Similar to Chrome | Partner Center registration |
| Safari App Store | $99/year | Days to weeks | Apple Developer Program, notarization |
Testing Your Extension
Thorough testing across browsers is essential for cross-browser extensions. Testing strategies should cover functional correctness, UI behavior, and performance characteristics.
Manual Testing
Manual testing on each target browser reveals visual and behavioral differences. Chrome, Firefox, and Edge each have extension management interfaces with developer-focused features. Test on all target browsers throughout development to catch visual and behavioral differences.
Loading unpacked extensions:
- Open the browser's extensions management page
- Enable "Developer mode"
- Click "Load unpacked" and select your build directory
Automated Testing
Automated tests can run across browsers using frameworks like Playwright or Puppeteer. These tests can interact with extension popups, content scripts, and background service workers.
// tests/extension.spec.ts - Playwright test example
import { test, expect } from '@playwright/test';
test.describe('Extension popup', () => {
test('popup loads and shows correct title', async ({ context }) => {
const extensionPath = '.output/chromium';
const extension = await context.loadExtension(extensionPath);
// Open popup by clicking extension action
const popup = await extension.newPage();
await popup.click('#extension-action');
// Verify content loaded
await expect(popup.locator('h1')).toHaveText('My Extension');
});
test('settings persist after close', async ({ context }) => {
const extensionPath = '.output/chromium';
const extension = await context.loadExtension(extensionPath);
const popup = await extension.newPage();
// Change setting
await popup.click('#enable-toggle');
await popup.close();
// Reopen and verify setting persisted
const popup2 = await extension.newPage();
await expect(popup2.locator('#status')).toHaveText('Enabled');
});
});
For content script testing, you can navigate to test pages and verify your scripts inject correctly. Background service worker testing requires mocking browser APIs or using a testing framework specifically designed for extensions.
Best Practices and Advanced Topics
Security Considerations
Extensions have significant access to browser functionality, making security paramount. Follow the principle of least privilege for permissions--only request what you truly need. Never include remote code in your extension package; all JavaScript must be bundled. Sanitize all data from external sources to prevent XSS attacks. Use Content Security Policy headers in popup and options pages to restrict script sources. Audit dependencies regularly for vulnerabilities using tools like npm audit or Snyk.
Performance Optimization
Well-performing extensions provide better user experience. Background service workers should initialize quickly and terminate cleanly--avoid expensive operations at startup. Content scripts should minimize DOM manipulation and use efficient selectors. Use storage efficiently to avoid quota issues; implement cleanup and compression for large datasets. Lazy-load features that aren't immediately needed to reduce initial load time.
Maintenance and Updates
Long-term maintenance ensures continued functionality. Monitor browser API changes in release notes--subscribe to browser extension developer blogs and changelogs. Test extensions with browser beta versions before stable releases to catch breaking changes early. Respond quickly to user feedback and issues reported through store reviews. Maintain documentation for future developers, including notes on cross-browser compatibility decisions.
Version Management
Keep your extension version updated following semantic versioning principles. Major version increments indicate breaking changes (like browser API migrations). Minor versions add features without breaking changes. Patch versions fix bugs. Each store may have different update propagation times--expect changes to take hours or days to reach all users.
Frequently Asked Questions
Conclusion
Building cross-browser extensions has never been more accessible, thanks to standardized APIs and modern development frameworks. The WebExtensions API provides a common foundation, while tools like WXT handle the complexities of cross-browser compatibility.
By understanding the platform fundamentals, following security best practices, and testing thoroughly across browsers, you can create extensions that reach users across the browser landscape. The investment in cross-browser development pays dividends in user reach and code maintainability.
Whether you're building a personal tool or a commercial product, the techniques covered in this guide provide a foundation for successful cross-browser extension development. Start with WXT for the best development experience, use the WebExtension polyfill for consistent API behavior, and test early and often on all target browsers.
If you need help building a browser extension for your business, our web development team has experience creating cross-browser extensions that integrate with your existing systems and delight users across the browser landscape. For organizations looking to enhance productivity through custom tools, our AI automation services can help identify opportunities where browser extensions streamline workflows and improve team efficiency.
Sources
- Mozilla Developer Network: Build a cross-browser extension
- Chrome for Developers: Extensions Documentation
- Redreamality: The 2025 State of Browser Extension Frameworks
- Callstack: How to Build a Chrome Extension
- WXT Documentation
- WebExtension Browser API Polyfill
- Chrome Web Store Developer Documentation
- Apple Developer: Safari Extensions