écrits/tutorial/2026/06
Tutorial26 juin 2026·28 min

Créer une extension de navigateur multiplateforme avec WXT, Vite et TypeScript

Apprenez à créer une extension de navigateur moderne et multiplateforme avec WXT — le framework propulsé par Vite qui apporte des points d'entrée basés sur les fichiers, le rechargement à chaud des modules, un stockage typé et une publication en une commande vers Chrome, Firefox, Edge et Safari.

Les extensions de navigateur, la manière moderne. WXT est aux extensions web ce que Nuxt est à Vue et Next.js à React — un framework clé en main qui gère le manifest, le bundling, le rechargement à chaud et la publication multinavigateur, pour que vous vous concentriez sur les fonctionnalités plutôt que sur la configuration du build.

Ce que vous allez apprendre

Dans ce tutoriel, vous allez :

  • Échafauder un projet WXT avec TypeScript et React
  • Comprendre les points d'entrée basés sur les fichiers (popup, arrière-plan, script de contenu)
  • Construire une interface popup et un script de contenu qui injecte React dans n'importe quelle page
  • Persister des données via l'API de stockage typée de WXT
  • Envoyer des messages entre la popup, l'arrière-plan et le script de contenu
  • Compiler et empaqueter l'extension pour Chrome et Firefox

À la fin, vous aurez une extension fonctionnelle « Reading Ruler » qui superpose une barre de focalisation sur n'importe quelle page web, avec un interrupteur et un sélecteur de couleur dans la popup — le tout partageant l'état via le stockage.


Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ et npm (ou pnpm/bun) installés
  • Des connaissances de base en TypeScript et React
  • Google Chrome ou Firefox pour les tests
  • Un éditeur de code (VS Code recommandé)

Vous n'avez pas besoin de connaître les API brutes de Manifest V3 — WXT génère le manifest pour vous et fournit un objet global browser qui fonctionne sur tous les navigateurs.


Pourquoi WXT plutôt que Manifest V3 brut ?

Écrire une extension de navigateur à la main signifie éditer manuellement manifest.json, configurer un bundler, copier les ressources, recharger l'extension à chaque modification et maintenir des builds séparés pour Chrome (MV3) et Firefox (MV2/MV3). WXT supprime toute cette friction.

AspectMV3 brutWXT
ManifestJSON écrit à la mainGénéré depuis les points d'entrée + config
BundlerÀ faire soi-même (webpack/rollup)Vite, préconfiguré
Rechargement à chaudRechargement manuelHMR pour l'UI, auto-rechargement des scripts
MultinavigateurConfigs séparéesOption wxt -b firefox
API navigateurchrome.* vs browser.*Objet browser unifié
PublicationZIP + upload manuelswxt zip et wxt submit

WXT est agnostique vis-à-vis des frameworks — il fonctionne avec TypeScript pur, React, Vue, Svelte et Solid via des modules officiels.


Étape 1 : Échafauder le projet

Créez un nouveau projet WXT à l'aide du starter interactif. À l'invite, choisissez le modèle React et npm comme gestionnaire de paquets.

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

Cela génère une structure de projet propre :

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

L'idée clé : chaque fichier dans entrypoints/ devient une partie de votre extension. WXT les inspecte au moment du build et génère le manifest correct automatiquement. Aucune inscription manuelle requise.

Votre package.json contient déjà les scripts dont vous avez besoin :

{
  "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"
  }
}

Lancez le serveur de développement maintenant — WXT démarrera une instance de navigateur fraîche avec votre extension déjà installée :

npm run dev

Laissez ceci en cours d'exécution. Désormais, l'enregistrement d'un fichier recharge à chaud la partie concernée de votre extension.


Étape 2 : Configurer le manifest

Ouvrez wxt.config.ts. C'est là que vous déclarez les permissions et tout champ de manifest que WXT ne peut pas déduire de votre code. Notre extension a besoin de la permission storage pour mémoriser les réglages de l'utilisateur.

// 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'],
  },
});

Vous ne définissez jamais manifest_version, background, content_scripts ou action ici — WXT les dérive de vos fichiers de points d'entrée. Vous déclarez uniquement les aspects transversaux comme permissions, name et host_permissions.

Astuce : WXT importe automatiquement ses helpers de base — defineBackground, defineContentScript, storage et l'objet global browser sont disponibles sans import. Le script postinstall exécute wxt prepare, qui génère les types TypeScript rendant cela possible. Si votre éditeur signale des types manquants, exécutez npm run postinstall.


Étape 3 : Définir un état partagé typé

La popup et le script de contenu ont tous deux besoin de lire et d'écrire les mêmes réglages. Créez un élément de stockage typé pour que la clé et le type de valeur soient définis en un seul endroit.

Créez 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,
    },
  },
);

Quelques points à noter :

  • La clé est préfixée par sync: — WXT prend en charge les zones local:, sync:, session: et managed:. Utiliser sync: signifie que les réglages suivent l'utilisateur sur les navigateurs où il est connecté.
  • fallback est renvoyé par getValue() quand rien n'est encore stocké, de sorte que les appelants ne manipulent jamais undefined.
  • defineItem renvoie getValue, setValue, removeValue et watch — un abonnement réactif déclenché à chaque changement de valeur, où qu'il survienne.

Cette source unique de vérité est la colonne vertébrale de toute l'extension.


Étape 4 : Construire le script de contenu

Le script de contenu s'exécute à l'intérieur de chaque page web. Il affichera une barre de focalisation au moyen de React, isolée dans un shadow root afin que le CSS de la page hôte ne puisse pas interférer avec nos styles.

Remplacez entrypoints/content.ts par 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();
 
    // Retire l'UI automatiquement si l'utilisateur désactive la règle.
    rulerSettings.watch((settings) => {
      if (!settings.enabled) ui.remove();
    });
  },
});

Points clés :

  • matches: ['<all_urls>'] injecte le script dans chaque page. Restreignez ceci à des domaines spécifiques en production.
  • cssInjectionMode: 'ui' indique à WXT d'injecter votre CSS dans le shadow root plutôt que dans la page, garantissant l'isolation.
  • createShadowRootUi monte votre arbre React à l'intérieur d'un shadow DOM. La page hôte ne peut ni voir ni styliser vos éléments.
  • ctx est le ContentScriptContext. WXT l'utilise pour nettoyer automatiquement votre UI lorsque l'utilisateur navigue sur des sites de type application monopage.

Créez maintenant le composant Ruler qui lit les réglages et suit la souris :

// 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',
      }}
    />
  );
}

Comme le composant s'abonne à rulerSettings.watch, changer la couleur ou la hauteur dans la popup met à jour la barre en direct sur la page — sans rechargement de page ni passage de messages manuel pour l'état.


Étape 5 : Construire l'interface popup

La popup est ce qui apparaît lorsque l'utilisateur clique sur l'icône de la barre d'outils. C'est une application HTML + React normale logée sous entrypoints/popup/. Remplacez 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>
  );
}

Remarquez le peu de plomberie requis. La popup écrit dans rulerSettings, le callback watch du script de contenu se déclenche, et la barre se met à jour instantanément. Le stockage est votre couche de synchronisation d'état — vous avez rarement besoin d'un passage de messages explicite pour les données partagées.


Étape 6 : Ajouter un script d'arrière-plan

Le script d'arrière-plan (un service worker en MV3) est le coordinateur toujours disponible de l'extension. Nous l'utiliserons pour basculer la règle avec un raccourci clavier et pour définir des valeurs par défaut raisonnables à l'installation.

Remplacez entrypoints/background.ts :

// entrypoints/background.ts
import { rulerSettings } from '@/utils/settings';
 
export default defineBackground(() => {
  // Bascule la règle quand l'utilisateur appuie sur le raccourci configuré.
  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.');
    }
  });
});

La fonction main de defineBackground ne peut pas être async — enregistrez vos écouteurs de façon synchrone, puis effectuez le travail asynchrone à l'intérieur. Cela garantit que le service worker capture les événements déclenchés immédiatement après son réveil.

Pour enregistrer le raccourci clavier, ajoutez un bloc commands à votre 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',
      },
    },
  },
});

Pourquoi utiliser l'objet global browser plutôt que chrome ? WXT fournit un objet browser normalisé construit sur le polyfill WebExtension. Le même code s'exécute sans modification sur Chrome, Firefox, Edge et Safari — sans branches de détection de fonctionnalités, sans divergence chrome vs browser.


Étape 7 : Messagerie entre contextes

Le stockage couvre l'état partagé, mais il faut parfois une requête/réponse explicite — par exemple, demander à l'onglet actif « quel est ton temps de lecture ? ». WXT recommande @webext-core/messaging pour une couche typée au-dessus de browser.runtime.sendMessage.

npm install @webext-core/messaging

Définissez votre protocole une seule fois :

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

Traitez-le dans le main du script de contenu :

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

Et appelez-le depuis la popup ou l'arrière-plan avec une inférence de type complète :

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.`);

Le type de retour de sendMessage est inféré depuis ProtocolMap, de sorte qu'une faute de frappe dans le nom du message ou un mauvais type d'argument devient une erreur de compilation, et non une surprise à l'exécution.


Étape 8 : Tester dans le navigateur

Avec npm run dev en cours d'exécution, le navigateur de développement de WXT a déjà chargé votre extension. Essayez le parcours complet :

  1. Cliquez sur l'icône Reading Ruler dans la barre d'outils pour ouvrir la popup.
  2. Basculez Enabled — une barre colorée apparaît et suit votre souris sur la page.
  3. Changez la couleur et la hauteur — la barre se met à jour en direct sans rechargement.
  4. Appuyez sur Alt+R — la commande d'arrière-plan bascule la règle.
  5. Ouvrez un second onglet — comme le stockage est sync:, vos réglages vous suivent.

Si vous modifiez un fichier source, WXT recharge à chaud : les modifications de la popup s'appliquent instantanément, et les modifications des scripts de contenu/arrière-plan déclenchent un rechargement automatique de l'extension.

Pour tester Firefox en parallèle :

npm run dev:firefox

WXT lance une instance Firefox avec un manifest adapté à Firefox — sans aucune modification de configuration.


Étape 9 : Compiler et empaqueter pour la publication

Quand vous êtes prêt à livrer, produisez des builds de production optimisés et des fichiers ZIP prêts pour les stores :

# Build de production (sortie dans .output/chrome-mv3/)
npm run build
 
# Zip pour le Chrome Web Store
npm run zip
 
# Build et zip Firefox
npm run zip:firefox

wxt zip crée une archive prête à l'upload dans .output/. Pour Firefox, WXT génère également un sources.zip contenant votre code source, que Mozilla exige pour la revue.

WXT peut même automatiser l'étape d'upload. Configurez les identifiants des stores et exécutez wxt submit pour publier sur le Chrome Web Store, Firefox Add-ons et Edge Add-ons en une seule commande — idéal pour les pipelines CI.


Tester votre implémentation

Vérifiez l'extension de bout en bout :

  • Exactitude du manifest : ouvrez .output/chrome-mv3/manifest.json et confirmez que permissions, commands et les entrées content_scripts/background générées correspondent à votre code.
  • Isolation : chargez la règle sur un site riche en CSS (par ex. une page d'actualités) et confirmez que les styles de l'hôte ne fuient pas dans votre UI shadow root, et inversement.
  • Persistance : basculez les réglages, fermez et rouvrez le navigateur, et confirmez qu'ils survivent.
  • Multinavigateur : exécutez npm run dev:firefox et répétez le test de fumée.

Dépannage

browser is not defined ou types d'auto-import manquants. Exécutez npm run postinstall (qui appelle wxt prepare) pour régénérer les types .wxt/, puis redémarrez le serveur TypeScript de votre éditeur.

Les styles du script de contenu débordent dans la page. Assurez-vous d'avoir défini cssInjectionMode: 'ui' et de monter via createShadowRootUi, et non un simple document.body.append.

Le service worker semble « mort ». Les service workers MV3 s'endorment en cas d'inactivité. Enregistrez tous les écouteurs d'événements de façon synchrone en haut de defineBackground — les écouteurs ajoutés à l'intérieur d'un await peuvent manquer les événements précoces.

La valeur de stockage est undefined. Fournissez toujours un fallback dans defineItem, et confirmez que le préfixe de zone (local:, sync:, session:) correspond à la permission storage que vous avez déclarée.

Le build Firefox échoue à la soumission. Firefox exige une archive source ; wxt zip -b firefox la génère automatiquement — uploadez à la fois le ZIP du build et sources.zip.


Étapes suivantes

  • Ajoutez une page d'options : créez entrypoints/options/ pour un écran de réglages complet, distinct de la popup compacte.
  • Essayez d'autres frameworks : remplacez @wxt-dev/module-react par @wxt-dev/module-vue ou @wxt-dev/module-svelte — les points d'entrée restent identiques.
  • Explorez les modules WXT : les modules auto-icons et i18n suppriment encore plus de code répétitif.
  • Branchez la CI : combinez wxt zip et wxt submit dans un workflow GitHub Actions pour des versions en un clic.

Pour des lectures connexes, consultez nos tutoriels sur la création d'une extension Chrome avec Manifest V3 et Vite 6 avec React et TypeScript.


Conclusion

WXT transforme le développement d'extensions de navigateur, d'une corvée manuelle et sujette aux erreurs, en un flux de travail moderne propulsé par Vite. Vous avez échafaudé un projet, défini des points d'entrée basés sur les fichiers, isolé une UI React dans un shadow root, synchronisé l'état via un stockage typé, coordonné la logique dans un service worker d'arrière-plan et empaqueté le résultat pour plusieurs navigateurs — le tout sans écrire une seule ligne de manifest.json à la main.

Les mêmes patterns passent à l'échelle, d'un projet d'un week-end à une extension publiée sur plusieurs stores. Avec des API navigateur unifiées, le rechargement à chaud et la publication en une commande, WXT vous permet de consacrer votre temps à ce que les utilisateurs voient réellement plutôt qu'à combattre la plateforme.