writing/tutorial/2026/06
TutorialJun 26, 2026·28 min read

Build a Cross-Browser Extension with WXT, Vite, and TypeScript

Learn how to build a modern, cross-browser web extension with WXT — the Vite-powered framework that brings file-based entrypoints, hot module replacement, type-safe storage, and one-command publishing to Chrome, Firefox, Edge, and Safari.

Browser extensions, the modern way. WXT is to web extensions what Nuxt is to Vue and Next.js is to React — a batteries-included framework that handles the manifest, bundling, hot reload, and cross-browser publishing so you can focus on features instead of build config.

What You Will Learn

In this tutorial, you will:

  • Scaffold a WXT project with TypeScript and React
  • Understand file-based entrypoints (popup, background, content script)
  • Build a popup UI and a content script that injects React into any page
  • Persist data with WXT's type-safe storage API
  • Send messages between the popup, background, and content script
  • Build and package the extension for Chrome and Firefox

By the end you will have a working "Reading Ruler" extension that overlays a focus bar on any web page, with a toggle and color picker in the popup — all sharing state through storage.


Prerequisites

Before starting, ensure you have:

  • Node.js 20+ and npm (or pnpm/bun) installed
  • Basic TypeScript and React knowledge
  • Google Chrome or Firefox for testing
  • A code editor (VS Code recommended)

You do not need to know the raw Manifest V3 APIs — WXT generates the manifest for you and provides a unified browser global that works across every browser.


Why WXT Instead of Raw Manifest V3?

Writing a browser extension by hand means hand-editing manifest.json, wiring up a bundler, copying assets, reloading the extension on every change, and maintaining separate builds for Chrome (MV3) and Firefox (MV2/MV3). WXT removes all of that friction.

ConcernRaw MV3WXT
ManifestHand-written JSONGenerated from entrypoints + config
BundlerDIY (webpack/rollup)Vite, preconfigured
Hot reloadManual extension reloadHMR for UI, auto-reload for scripts
Cross-browserSeparate configswxt -b firefox flag
Browser APIchrome.* vs browser.*Unified browser global
PublishingManual ZIP + uploadwxt zip and wxt submit

WXT is framework-agnostic — it works with vanilla TypeScript, React, Vue, Svelte, and Solid through official modules.


Step 1: Scaffold the Project

Create a new WXT project using the interactive starter. When prompted, choose the React template and npm as the package manager.

npx wxt@latest init reading-ruler
cd reading-ruler
npm install

This generates a clean project structure:

reading-ruler/
├── entrypoints/
│   ├── background.ts
│   ├── content.ts
│   └── popup/
│       ├── App.tsx
│       ├── index.html
│       └── main.tsx
├── public/
│   └── icon/
├── wxt.config.ts
├── package.json
└── tsconfig.json

The key idea: every file in entrypoints/ becomes part of your extension. WXT inspects them at build time and generates the correct manifest automatically. No manual registration required.

Your package.json already contains the scripts you need:

{
  "scripts": {
    "dev": "wxt",
    "dev:firefox": "wxt -b firefox",
    "build": "wxt build",
    "build:firefox": "wxt build -b firefox",
    "zip": "wxt zip",
    "zip:firefox": "wxt zip -b firefox",
    "postinstall": "wxt prepare"
  }
}

Start the dev server now — WXT will launch a fresh browser instance with your extension already installed:

npm run dev

Leave this running. From here on, saving a file hot-reloads the relevant part of your extension.


Step 2: Configure the Manifest

Open wxt.config.ts. This is where you declare permissions and any manifest fields WXT cannot infer from your code. Our extension needs storage permission to remember the user's settings.

// wxt.config.ts
import { defineConfig } from 'wxt';
 
export default defineConfig({
  modules: ['@wxt-dev/module-react'],
  manifest: {
    name: 'Reading Ruler',
    description: 'Overlay a focus bar on any web page to aid reading.',
    permissions: ['storage'],
  },
});

You never set manifest_version, background, content_scripts, or action here — WXT derives those from your entrypoint files. You only declare cross-cutting concerns like permissions, name, and host_permissions.

Tip: WXT auto-imports its core helpers — defineBackground, defineContentScript, storage, and the browser global are available without imports. The postinstall script runs wxt prepare, which generates the TypeScript types that make this work. If your editor complains about missing types, run npm run postinstall.


Step 3: Define Type-Safe Shared State

Both the popup and the content script need to read and write the same settings. Create a typed storage item so the key and value type are defined in exactly one place.

Create utils/settings.ts:

// utils/settings.ts
export interface RulerSettings {
  enabled: boolean;
  color: string;
  height: number;
}
 
export const rulerSettings = storage.defineItem<RulerSettings>(
  'sync:rulerSettings',
  {
    fallback: {
      enabled: false,
      color: '#7c3aed',
      height: 28,
    },
  },
);

A few things to note:

  • The key is prefixed with sync: — WXT supports local:, sync:, session:, and managed: areas. Using sync: means the settings follow the user across signed-in browsers.
  • fallback is returned by getValue() when nothing is stored yet, so callers never deal with undefined.
  • defineItem returns getValue, setValue, removeValue, and watch — a reactive subscription that fires whenever the value changes anywhere.

This single source of truth is the backbone of the whole extension.


Step 4: Build the Content Script

The content script runs inside every web page. It will render a draggable focus bar using React, isolated inside a shadow root so the host page's CSS cannot interfere with our styles.

Replace entrypoints/content.ts with entrypoints/content/index.tsx:

// entrypoints/content/index.tsx
import ReactDOM from 'react-dom/client';
import { rulerSettings } from '@/utils/settings';
import Ruler from './Ruler';
 
export default defineContentScript({
  matches: ['<all_urls>'],
  cssInjectionMode: 'ui',
 
  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: 'reading-ruler-ui',
      position: 'overlay',
      anchor: 'body',
      onMount: (container) => {
        const root = ReactDOM.createRoot(container);
        root.render(<Ruler />);
        return root;
      },
      onRemove: (root) => root?.unmount(),
    });
 
    ui.mount();
 
    // Auto-remove the UI if the user disables the ruler.
    rulerSettings.watch((settings) => {
      if (!settings.enabled) ui.remove();
    });
  },
});

Key points:

  • matches: ['<all_urls>'] injects the script into every page. Narrow this to specific domains in production.
  • cssInjectionMode: 'ui' tells WXT to inject your CSS into the shadow root instead of the page, guaranteeing isolation.
  • createShadowRootUi mounts your React tree inside a shadow DOM. The host page can neither see nor style your elements.
  • ctx is the ContentScriptContext. WXT uses it to automatically clean up your UI when the user navigates away on single-page-app sites.

Now create the Ruler component that reads settings and follows the mouse:

// entrypoints/content/Ruler.tsx
import { useEffect, useState } from 'react';
import { rulerSettings, type RulerSettings } from '@/utils/settings';
 
export default function Ruler() {
  const [settings, setSettings] = useState<RulerSettings | null>(null);
  const [top, setTop] = useState(0);
 
  useEffect(() => {
    rulerSettings.getValue().then(setSettings);
    const unwatch = rulerSettings.watch(setSettings);
    return unwatch;
  }, []);
 
  useEffect(() => {
    const onMove = (e: MouseEvent) => setTop(e.clientY);
    window.addEventListener('mousemove', onMove);
    return () => window.removeEventListener('mousemove', onMove);
  }, []);
 
  if (!settings?.enabled) return null;
 
  return (
    <div
      style={{
        position: 'fixed',
        left: 0,
        right: 0,
        top: top - settings.height / 2,
        height: settings.height,
        background: settings.color,
        opacity: 0.25,
        pointerEvents: 'none',
        zIndex: 2147483647,
        transition: 'top 60ms linear',
      }}
    />
  );
}

Because the component subscribes to rulerSettings.watch, changing the color or height in the popup updates the bar live on the page — no page reload, no manual message passing for state.


Step 5: Build the Popup UI

The popup is what appears when the user clicks the toolbar icon. It is a normal HTML + React app living under entrypoints/popup/. Replace entrypoints/popup/App.tsx:

// entrypoints/popup/App.tsx
import { useEffect, useState } from 'react';
import { rulerSettings, type RulerSettings } from '@/utils/settings';
 
export default function App() {
  const [settings, setSettings] = useState<RulerSettings | null>(null);
 
  useEffect(() => {
    rulerSettings.getValue().then(setSettings);
  }, []);
 
  async function update(patch: Partial<RulerSettings>) {
    if (!settings) return;
    const next = { ...settings, ...patch };
    setSettings(next);
    await rulerSettings.setValue(next);
  }
 
  if (!settings) return <p>Loading…</p>;
 
  return (
    <main style={{ width: 240, padding: 16, fontFamily: 'system-ui' }}>
      <h1 style={{ fontSize: 16, marginBottom: 12 }}>Reading Ruler</h1>
 
      <label style={{ display: 'flex', justifyContent: 'space-between' }}>
        Enabled
        <input
          type="checkbox"
          checked={settings.enabled}
          onChange={(e) => update({ enabled: e.target.checked })}
        />
      </label>
 
      <label style={{ display: 'flex', justifyContent: 'space-between', marginTop: 10 }}>
        Color
        <input
          type="color"
          value={settings.color}
          onChange={(e) => update({ color: e.target.value })}
        />
      </label>
 
      <label style={{ display: 'block', marginTop: 10 }}>
        Height: {settings.height}px
        <input
          type="range"
          min={8}
          max={80}
          value={settings.height}
          onChange={(e) => update({ height: Number(e.target.value) })}
          style={{ width: '100%' }}
        />
      </label>
    </main>
  );
}

Notice how little plumbing this requires. The popup writes to rulerSettings, the content script's watch callback fires, and the bar updates instantly. Storage is your state-sync layer — you rarely need explicit message passing for shared data.


Step 6: Add a Background Script

The background script (a service worker in MV3) is the extension's always-available coordinator. We will use it to toggle the ruler with a keyboard command and to set sensible defaults on install.

Replace entrypoints/background.ts:

// entrypoints/background.ts
import { rulerSettings } from '@/utils/settings';
 
export default defineBackground(() => {
  // Toggle the ruler when the user presses the configured shortcut.
  browser.commands.onCommand.addListener(async (command) => {
    if (command !== 'toggle-ruler') return;
    const current = await rulerSettings.getValue();
    await rulerSettings.setValue({ ...current, enabled: !current.enabled });
  });
 
  browser.runtime.onInstalled.addListener((details) => {
    if (details.reason === 'install') {
      console.log('Reading Ruler installed.');
    }
  });
});

The main function of defineBackground cannot be async — register your listeners synchronously, then do async work inside them. This guarantees the service worker captures events that fire immediately after it wakes.

To register the keyboard shortcut, add a commands block to your manifest:

// wxt.config.ts
import { defineConfig } from 'wxt';
 
export default defineConfig({
  modules: ['@wxt-dev/module-react'],
  manifest: {
    name: 'Reading Ruler',
    description: 'Overlay a focus bar on any web page to aid reading.',
    permissions: ['storage'],
    commands: {
      'toggle-ruler': {
        suggested_key: { default: 'Alt+R' },
        description: 'Toggle the reading ruler',
      },
    },
  },
});

Why use the browser global instead of chrome? WXT ships a normalized browser object built on the WebExtension polyfill. The same code runs unchanged on Chrome, Firefox, Edge, and Safari — no feature-detection branches, no chrome vs browser divergence.


Step 7: Messaging Between Contexts

Storage covers shared state, but sometimes you need an explicit request/response — for example, asking the active tab "what is your reading time?" WXT recommends @webext-core/messaging for a type-safe layer over browser.runtime.sendMessage.

npm install @webext-core/messaging

Define your protocol once:

// utils/messaging.ts
import { defineExtensionMessaging } from '@webext-core/messaging';
 
interface ProtocolMap {
  getWordCount(): number;
}
 
export const { sendMessage, onMessage } =
  defineExtensionMessaging<ProtocolMap>();

Handle it in the content script's main:

import { onMessage } from '@/utils/messaging';
 
onMessage('getWordCount', () => {
  return document.body.innerText.trim().split(/\s+/).length;
});

And call it from the popup or background with full type inference:

import { sendMessage } from '@/utils/messaging';
 
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const count = await sendMessage('getWordCount', undefined, tab.id);
console.log(`This page has ${count} words.`);

The return type of sendMessage is inferred from ProtocolMap, so a typo in the message name or a wrong argument type is a compile error, not a runtime surprise.


Step 8: Test in the Browser

With npm run dev running, the WXT dev browser already has your extension loaded. Try the full flow:

  1. Click the Reading Ruler icon in the toolbar to open the popup.
  2. Toggle Enabled — a colored bar appears and follows your mouse on the page.
  3. Change the color and height — the bar updates live without reloading.
  4. Press Alt+R — the background command toggles the ruler off and on.
  5. Open a second tab — because storage is sync:, your settings carry over.

If you change any source file, WXT hot-reloads: popup edits update instantly, and content/background script edits trigger an automatic extension reload.

To test Firefox in parallel:

npm run dev:firefox

WXT launches a Firefox instance with a Firefox-flavored manifest — no config changes needed.


Step 9: Build and Package for Publishing

When you are ready to ship, produce optimized production builds and store-ready ZIP files:

# Production build (output in .output/chrome-mv3/)
npm run build
 
# Zip for the Chrome Web Store
npm run zip
 
# Firefox build and zip
npm run zip:firefox

wxt zip creates an upload-ready archive in .output/. For Firefox, WXT also generates a sources.zip containing your source code, which Mozilla requires for review.

WXT can even automate the upload step. Configure store credentials and run wxt submit to publish to the Chrome Web Store, Firefox Add-ons, and Edge Add-ons in one command — ideal for CI pipelines.


Testing Your Implementation

Verify the extension end-to-end:

  • Manifest correctness: open .output/chrome-mv3/manifest.json and confirm permissions, commands, and the generated content_scripts/background entries match your code.
  • Isolation: load the ruler on a CSS-heavy site (e.g. a news page) and confirm the host styles do not leak into your shadow-root UI, and vice versa.
  • Persistence: toggle settings, close and reopen the browser, and confirm they survive.
  • Cross-browser: run npm run dev:firefox and repeat the smoke test.

Troubleshooting

browser is not defined or missing auto-import types. Run npm run postinstall (which calls wxt prepare) to regenerate .wxt/ types, then restart the TypeScript server in your editor.

Content script styles bleed into the page. Make sure you set cssInjectionMode: 'ui' and mount through createShadowRootUi, not a plain document.body.append.

Service worker seems "dead." MV3 service workers sleep when idle. Register all event listeners synchronously at the top of defineBackground — listeners added inside an await may miss early events.

Storage value is undefined. Always provide a fallback in defineItem, and confirm the area prefix (local:, sync:, session:) matches the storage permission you declared.

Firefox build fails on submit. Firefox requires a source archive; wxt zip -b firefox generates it automatically — upload both the build ZIP and sources.zip.


Next Steps

  • Add an options page: create entrypoints/options/ for a full settings screen, separate from the compact popup.
  • Try other frameworks: swap @wxt-dev/module-react for @wxt-dev/module-vue or @wxt-dev/module-svelte — entrypoints stay identical.
  • Explore WXT modules: the auto-icons and i18n modules remove even more boilerplate.
  • Wire up CI: combine wxt zip and wxt submit in a GitHub Actions workflow for one-click releases.

For related reading, see our tutorials on building a Chrome extension with Manifest V3 and Vite 6 with React and TypeScript.


Conclusion

WXT turns browser-extension development from a manual, error-prone chore into a modern, Vite-powered workflow. You scaffolded a project, defined file-based entrypoints, isolated a React UI inside a shadow root, synced state through type-safe storage, coordinated logic in a background service worker, and packaged the result for multiple browsers — all without hand-writing a single line of manifest.json.

The same patterns scale from a weekend hack to a published, multi-store extension. With unified browser APIs, hot reload, and one-command publishing, WXT lets you spend your time on what users actually see instead of fighting the platform.