WXT Extension Boilerplate

  • Templates and infrastructure
  • Templates
  • 2024
  • Open source

WXT, React 19, TypeScript, Tailwind CSS v4 and 3 more

WXT Extension Boilerplate is where I start when an idea needs to live in the browser. It is WXT with React 19, TypeScript, Tailwind CSS v4 and pnpm. On top sit a typed storage hook, a typed message layer, ESLint, Prettier and two GitHub Actions workflows. One source tree builds and zips for Chrome and Firefox.

It began in August 2024 as WXT’s React template, with Prettier, a pre-commit hook and Tailwind 3 added the same day. In December 2025 I rebuilt it on React 19, WXT 0.20 and Tailwind 4. That rebuild added the pieces every extension ends up needing: storage that behaves like React state, and messages with types.

pnpmsession

# one source tree, two browserspnpm installwxt prepare: .wxt/tsconfig.json and auto-import types writtenpnpm lint && pnpm compileeslint . clean, tsc --noEmit cleanpnpm build.output/chrome-mv3/ ready to load unpackedpnpm zip && pnpm zip:firefox.output/wxt-web-extension-react-ts-boilerplate-0.0.1-chrome.zip.output/wxt-web-extension-react-ts-boilerplate-0.0.1-firefox.zip

4 commands5 lines of output

A web extension starter with WXT, React 19, TypeScript and Tailwind CSS v4, plus typed storage and messaging, ESLint, Prettier and CI.
Chapter
Templates and infrastructure
Category
Templates
Year
Availability
Open source
Last commit
stack7 parts
  • WXT
  • React 19
  • TypeScript
  • Tailwind CSS v4
  • ESLint
  • Prettier
  • GitHub Actions

WXT, Browser Extension, React, TypeScript, Tailwind CSS

Where WXT stops

WXT already answers the build questions. It generates the manifest from files under entrypoints/, reloads during development, and targets several browsers from one config. Its React template stops at a counter, though. Everything after that is application glue, and the browser APIs underneath are untyped. browser.runtime.sendMessage accepts any object and resolves with whatever a listener sends back. A renamed message type still compiles, then fails at runtime in a different context from the one that sent it. Settings have the same shape of problem. A popup and a content script read the same key, and nothing keeps either one current when the other writes.

Three contexts, one contract

entrypoints/background.ts is the service worker, entrypoints/content.ts a content script matching *://*.google.com/*, and entrypoints/popup/ the toolbar popup. The popup’s index.html declares manifest.type as browser_action, and main.tsx mounts App under React.StrictMode. The postinstall script runs wxt prepare, which writes the .wxt/tsconfig.json that the root tsconfig.json extends. That generated config is what lets auto-imports from hooks/ and utils/ type-check. utils/messages.ts holds one MessageTypes interface mapping each message name to a request type and a response type. sendMessage and sendMessageToTab wrap a payload as { type, data }. onMessage answers with { success, data }, or with { success: false, error } when the handler throws. The sender rethrows that as an Error, so a failure surfaces at the call site.

Storage as React state

hooks/useStorage.ts turns a WXT storage item into a [value, setValue] pair. Keys are a template literal type, so only local:, sync: and session: prefixes compile. On mount the hook reads the key with storage.getItem and falls back to the default when nothing is stored. Once that read settles, it subscribes with storage.watch, so a write anywhere reaches every open view using the same key. The setter updates React state first and then calls storage.setItem. A failed write is logged, not rolled back. The first render always shows the default, and the setter takes a value rather than an updater function. utils/env.ts exposes VITE_ variables as one typed env object with isDev, isProd and mode, and utils/cn.ts wraps clsx.

Trade-offs I accepted

  • A hand-written message layer instead of a messaging package. It is one file under 120 lines. Each onMessage call adds its own listener, though, so every message wakes every listener before a type check discards it.
  • clsx alone, without tailwind-merge. One small dependency covers conditional classes. In exchange, cn('p-2', 'p-4') keeps both, and the stylesheet decides which wins.
  • Keeping WXT’s example popup. It proves the React and CSS wiring on the first pnpm dev, and it ships unless I delete it. VIN Highlighter was cut from the 2024 version, and it still carries this repository’s package name and that counter.
  • Typing environment variables without validating them. This is the one I would revisit. validateEnv() exists, but its required list is empty and nothing calls it, so a missing key is undefined at runtime.

Starting an extension

A new extension starts with a clone and a rename in package.json, since WXT reads the manifest name and the zip file names from it. A new message is declared once in MessageTypes, answered with onMessage inside defineBackground, and sent with sendMessage from the popup. The compiler then checks the payload and the result at every call site.

bash7 lines
pnpm install                  # postinstall runs wxt prepare
pnpm dev                      # opens Chrome with the extension loaded
pnpm dev:firefox
pnpm lint && pnpm compile
pnpm analyze                  # ANALYZE=true build, opens dist/stats.html
pnpm zip && pnpm zip:firefox  # archives land in .output/
git tag v0.1.0 && git push origin v0.1.0

wxt.config.ts adds rollup-plugin-visualizer only when ANALYZE is set, with gzip and Brotli sizes. Pushing a v* tag runs release.yml. It type-checks, zips both builds and attaches every .output/*.zip to a GitHub release with generated notes.

Gaps and upkeep

There are no tests and no test runner, so CI stops at eslint ., tsc --noEmit and wxt build on an ubuntu-24.04-arm runner. The release workflow skips lint, and no tag or release exists yet. background.ts registers no handler, so the README’s PING example fails until one is added. The README asks for Node 20 or later, while package.json pins Node 24.x and pnpm 12.11.2. dotenv sits in devDependencies with no importer, since Vite loads .env itself. Prettier’s tailwindFunctions lists clsx but not cn, so classes inside the recommended helper go unsorted. The README places Firefox builds in .output/firefox-mv3/, but WXT defaults Firefox to Manifest V2. It is MIT licensed, and an October 2026 refresh moved it to WXT 0.21, TypeScript 6 and Vite 8.

All 43 projects