Stack Guide

How to build a browser extension that ships everywhere

Extensions are the last corner of web development where the platform actively fights you: an ephemeral background worker, four stores with four opinions, and a permissions model where asking for one word too many costs you two weeks in review. This guide picks the stack that removes most of that, then explains the parts nobody can remove for you.

About 6 min read. Recommendations verified August 2026.

The recommended extension stack

One codebase, four browsers, and a dev server with hot reload that actually reloads the extension. That last part is the reason to use a framework at all.

Layer Pick Why
Framework WXT The Nuxt of extensions: file-based entrypoints, a generated manifest, and Vite underneath. The most actively maintained option and the best cross-browser story.
Language TypeScript Messages between content script, background, and popup are the bug factory in every extension. Typed message payloads shut that down.
UI React 19 For the popup, options page, and side panel. WXT also templates Vue, Svelte, and Solid if you prefer; nothing in the stack depends on React.
Styling Tailwind v4 Scoped inside a shadow root for injected UI so the host page's CSS cannot reach your components and yours cannot wreck theirs.
Storage WXT storage A typed wrapper over the extension storage API with defaults, versioned migrations, and watchers. Sync storage for preferences, local for anything large.
Injected UI Shadow DOM WXT's shadow root helper mounts your component tree into any page with styles isolated and cleanup handled on navigation.
Network rules declarativeNetRequest The only blocking or redirect mechanism Manifest V3 offers. Static rule files review far more smoothly than dynamic rules built at runtime.
Testing Vitest and Playwright Vitest with a fake browser API for logic, then Playwright launching a persistent context with the unpacked build for the flows that touch a real page.
Cross-browser build wxt build -b firefox Per-browser output directories and zips from one source tree, including the MV2-flavored differences Firefox still expects in places.
Publishing wxt submit Uploads to the Chrome Web Store, Edge Add-ons, and Firefox AMO from CI, including the source archive AMO requires for bundled code.

The verdict

WXT with TypeScript and React. Plasmo is the credible runner-up with deeper React-specific tutorials, and CRXJS is a Vite plugin rather than a framework whose development has slowed noticeably. For a new project in 2026, WXT is the default.

Manifest V3 realities that break naive code

Manifest V3 is now the only game in Chrome, and its constraints are not stylistic preferences - they will silently break an extension that was written like a web page.

The background is a service worker, and it dies. It terminates after a short idle period and restarts on the next event with a fresh global scope. Any variable you set at the top level is gone. Persist state in the storage API, replace setTimeout and setInterval with the alarms API, and write every handler so that a cold start is the normal case rather than the exception.

No remote code, ever. Loading a script from a CDN, evaluating a string, or shipping an interpreter for downloaded logic is an automatic rejection. Everything executable lives in your package. This is also why a bundler that inlines dependencies is not optional.

Permissions are the review. Ask for the narrowest set that works: activeTab instead of host permissions on every site, optional_host_permissions requested at the moment the user triggers the feature, and scripting only if you actually inject. Requesting permissions your code never calls is the single most common rejection reason, and broad host access is what turns a two-day review into a three-week one.

No DOM in the worker. Service workers have no document, so parsing HTML, using canvas, or playing audio needs an offscreen document. Firefox's background pages behave differently again, which is exactly the kind of divergence a framework absorbs for you.

Content script and background patterns worth copying

One typed message contract

Define a single discriminated union of message types in a shared module and import it in the content script, the background, and the popup. Every cross-context call goes through one helper that returns a typed promise. It costs an hour and eliminates the entire category of silent string-mismatch bugs.

Content scripts do DOM, background does network

Keep fetches, credentials, and API keys in the background worker where the page cannot see them, and let the content script handle only what needs the document. It also sidesteps most cross-origin problems, because the worker is not bound by the host page's origin.

Know which world you are in

Content scripts run in an isolated world by default, so they see the DOM but not the page's JavaScript variables. Reaching page globals means an explicit main-world script and message passing back. Guessing wrong here produces code that works in your test page and nowhere else.

Survive single-page navigation

On sites that never do a full page load, your injected UI needs to re-mount on route changes and clean up after itself. WXT's content script context gives you an invalidation signal; without one you leak observers and end up with three copies of your button.

Surviving store review on Chrome and Firefox

Review timing in 2026 is roughly: updates to an established listing in 24-48 hours, a new extension from an established account in two to five business days, and a first submission from a brand-new developer account in one to two weeks. Broad host permissions or sensitive APIs routinely push that to three weeks. Plan launch dates around the slow path.

  • Write the justifications like a human will read them. Chrome asks you to justify each permission and the single purpose of the extension. Vague answers get bounced; one plain sentence naming the feature that needs it does not.
  • Publish a real privacy policy at a real URL. Required if you touch user data at all, and a missing or generic one is a top rejection cause. Say what you collect, where it goes, and how long you keep it.
  • Give AMO your source. Firefox requires a source archive plus build instructions whenever the submitted code is bundled or minified, which is always with a modern toolchain. Automate it in the same job that builds the zip.
  • Ship the small version first. Get an approved listing with a narrow permission set, then expand. Reviewers treat an established, clean listing very differently from a first submission asking for access to every site.
  • Automate submission. Wire the build, zip, and upload for all three stores into a release job so a tag pushes everywhere. The GitHub Actions guide covers the workflow shape, and store API keys belong in encrypted secrets.

Browser extension questions, answered

Which browser extension framework should I use in 2026?

WXT, for almost every new project. It is the most actively maintained of the three, it is built on Vite so builds and hot reload are fast, and it produces per-browser bundles from one codebase. Plasmo remains a reasonable choice if you want its React-focused tutorials and hosted test tooling, and CRXJS is a Vite plugin rather than a framework whose release pace has slowed since 2025.

Can one codebase really target Chrome, Firefox, Edge, and Safari?

Mostly. Chrome and Edge are the same target in practice, and WXT handles the Firefox manifest differences for you, so those three come from one source tree with a build flag. Safari is the outlier: you convert the built extension with Apple's converter tool, then sign and distribute it through the App Store with an Apple developer account. Budget separate testing time for Safari rather than assuming parity.

Why does my Manifest V3 background script keep losing state?

Because it is a service worker, not a persistent page. Chrome terminates it after a short idle period and restarts it on the next event with a completely fresh global scope, so any variable held in module scope disappears. Persist anything that must survive into the storage API, replace timers with the alarms API, and treat every event handler as though the worker just cold-started, because most of the time it did.

How long does Chrome Web Store review take?

Updates to an existing listing usually clear in 24-48 hours. A new extension from an established developer account takes two to five business days, and a first submission from a brand-new account can take one to two weeks while Google evaluates account trust. Anything requesting broad host permissions or sensitive APIs regularly stretches to three weeks, so narrow your permissions before you plan a launch date.

Where to go next

Choose what renders in the popup over in the frontend directory, and set up the browser tests with the testing tools guide. Most extensions eventually need a small backend for sync or licensing - the API stack guide covers that, and the TypeScript cheatsheet is handy while you are typing those message contracts. Read the official docs at wxt.dev.