Cross-browser extension development using WebExtension API

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1212
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    852
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    815

Cross-browser Extension Development (WebExtension API)

WebExtension API is a standard supported by Chrome, Firefox, Edge, Opera, and (partially) Safari. A single codebase with conditional patches for browser differences is the right approach to cross-browser development. Copying and maintaining separate versions for each browser is an anti-pattern.

API compatibility by browser

API Chrome Firefox Edge Opera Safari
storage.local MV2/3 MV2/3 MV2/3 MV2/3 14+
storage.sync 15+
tabs 14+
scripting (MV3) 101+ 92+ 15.4+
declarativeNetRequest 113+ 92+ 15.4+
sidePanel 114+
webRequest MV2 only MV2 only MV2 only

Polyfill: webextension-polyfill

Mozilla developed webextension-polyfill — it converts chrome.* (Callback) to browser.* (Promise) and smooths differences between browsers:

npm install webextension-polyfill
import browser from 'webextension-polyfill';

// Same code for Chrome and Firefox
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
await browser.storage.local.set({ data: 'value' });
const result = await browser.storage.local.get('data');

Single manifest: MV2 vs MV3

Maintaining two manifests in parallel is standard practice for maximum coverage:

// scripts/build.js
const fs = require('fs');
const target = process.env.TARGET_BROWSER; // 'chrome', 'firefox', 'safari'

const manifests = {
  chrome: {
    manifest_version: 3,
    background: { service_worker: 'background.js' },
    action: { default_popup: 'popup.html' },
  },
  firefox: {
    manifest_version: 2,
    background: { scripts: ['background.js'], persistent: false },
    browser_action: { default_popup: 'popup.html' },
    browser_specific_settings: {
      gecko: { id: '[email protected]', strict_min_version: '109.0' },
    },
  },
  safari: {
    manifest_version: 3,
    background: { service_worker: 'background.js' },
    action: { default_popup: 'popup.html' },
    browser_specific_settings: {
      safari: { strict_min_version: '15.4' },
    },
  },
};

const base = JSON.parse(fs.readFileSync('./manifest.base.json', 'utf8'));
const merged = { ...base, ...manifests[target] };
fs.writeFileSync('./dist/manifest.json', JSON.stringify(merged, null, 2));

Project structure

my-extension/
├── src/
│   ├── background/
│   │   ├── index.ts
│   │   └── handlers/
│   ├── content/
│   │   ├── index.ts
│   │   └── modules/
│   ├── popup/
│   │   ├── App.tsx
│   │   └── index.tsx
│   ├── options/
│   │   └── App.tsx
│   └── shared/
│       ├── storage.ts      # wrapper over browser.storage
│       ├── messaging.ts    # typed messages
│       └── browser.ts      # current browser detection
├── manifest.base.json
├── manifests/
│   ├── mv2.json
│   └── mv3.json
├── vite.config.ts
└── package.json

Typed messages

Strict message typing between contexts prevents silent bugs:

// shared/messages.ts
export type Message =
  | { type: 'GET_PAGE_DATA'; url: string }
  | { type: 'SET_BADGE'; count: number; color?: string }
  | { type: 'OPEN_OPTIONS' }
  | { type: 'EXTRACT_TEXT'; selector: string };

export type MessageResponse<T extends Message> =
  T extends { type: 'GET_PAGE_DATA' } ? { title: string; meta: Record<string, string> } :
  T extends { type: 'EXTRACT_TEXT' }  ? { text: string } :
  void;

// Typed sendMessage
export async function sendMessage<T extends Message>(
  message: T
): Promise<MessageResponse<T>> {
  return browser.runtime.sendMessage(message) as Promise<MessageResponse<T>>;
}

// Typed onMessage
export function onMessage<T extends Message['type']>(
  type: T,
  handler: (msg: Extract<Message, { type: T }>, sender: browser.runtime.MessageSender) => Promise<MessageResponse<Extract<Message, { type: T }>>>
) {
  browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
    if (msg.type === type) {
      handler(msg, sender).then(sendResponse);
      return true;
    }
  });
}

Browser detection and conditional paths

// shared/browser.ts
export type BrowserName = 'chrome' | 'firefox' | 'safari' | 'edge' | 'opera';

export function detectBrowser(): BrowserName {
  // @ts-ignore
  if (typeof browser !== 'undefined' && browser.runtime?.id) {
    const ua = navigator.userAgent;
    if (ua.includes('Firefox/')) return 'firefox';
    if (ua.includes('OPR/')) return 'opera';
    if (ua.includes('Edg/')) return 'edge';
    // Safari determined via manifest
    if (!ua.includes('Chrome')) return 'safari';
  }
  return 'chrome';
}

export const IS_FIREFOX = detectBrowser() === 'firefox';
export const IS_SAFARI = detectBrowser() === 'safari';
export const SUPPORTS_SIDE_PANEL = detectBrowser() === 'chrome';

Building for multiple browsers with Vite

// vite.config.ts
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';

const browser = process.env.TARGET_BROWSER ?? 'chrome';
const manifestFile = browser === 'firefox'
  ? './manifests/mv2.json'
  : './manifests/mv3.json';

export default defineConfig({
  define: {
    __BROWSER__: JSON.stringify(browser),
    __IS_FIREFOX__: browser === 'firefox',
  },
  plugins: [crx({ manifest: require(manifestFile) })],
  build: {
    outDir: `dist/${browser}`,
  },
});
// package.json scripts
{
  "build:chrome":  "TARGET_BROWSER=chrome  vite build",
  "build:firefox": "TARGET_BROWSER=firefox vite build",
  "build:edge":    "TARGET_BROWSER=edge    vite build",
  "build:safari":  "TARGET_BROWSER=safari  vite build",
  "build:all":     "npm run build:chrome && npm run build:firefox && npm run build:edge"
}

CI/CD: build and publish all versions

# .github/workflows/publish.yml
name: Publish Extensions
on:
  push:
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build:all

      - name: Publish to Chrome Web Store
        uses: trstringer/manual-approval@v1
        env:
          CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
          CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
          CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}

      - name: Sign & Publish to Firefox AMO
        run: |
          npx web-ext sign \
            --source-dir dist/firefox \
            --api-key ${{ secrets.AMO_JWT_ISSUER }} \
            --api-secret ${{ secrets.AMO_JWT_SECRET }} \
            --channel listed

Testing cross-browser extension

// tests/messaging.test.ts (with @jest-environment jsdom)
import { sendMessage, onMessage } from '../src/shared/messaging';

// Mock browser.runtime
global.chrome = {
  runtime: {
    sendMessage: jest.fn((msg, cb) => cb({ data: 'test' })),
    onMessage: { addListener: jest.fn() },
  },
} as any;

test('sendMessage GET_PAGE_DATA returns data', async () => {
  const result = await sendMessage({ type: 'GET_PAGE_DATA', url: 'https://example.com' });
  expect(result).toHaveProperty('title');
});

For E2E — playwright + official extension running example:

const { chromium } = require('playwright');
const path = require('path');

const browser = await chromium.launchPersistentContext('', {
  headless: false,
  args: [
    `--disable-extensions-except=${path.resolve('./dist/chrome')}`,
    `--load-extension=${path.resolve('./dist/chrome')}`,
  ],
});

Timeline

Cross-browser extension (Chrome + Firefox + Edge) with single codebase, typing, auto-build, and publishing to three stores — 10–16 working days depending on functionality. Adding Safari (Xcode wrapper + App Store) adds 5–8 days. Store review timelines — separate and in parallel.