cd ..
$ cat building-chrome-extension-wxt.md

Building a Chrome Extension with WXT

How I built the ID Watermark Chrome extension using WXT, React, and Tailwind CSS — a practical walkthrough of content scripts, Shadow DOM isolation, and the development workflow.

decong20772026/07/133 min read

I built a Chrome extension for my ID Watermark tool. Here is how it went.

Why WXT?

WXT is a framework for building browser extensions. Think of it like Next.js for extensions — it handles manifest generation, HMR, TypeScript, and build tooling out of the box.

Before WXT, the typical extension setup required manual manifest.json wrangling, separate build configs for content scripts and background workers, and no HMR. WXT abstracts all of that.

{
  "manifest_version": 3,
  "name": "ID Watermark",
  "version": "1.0.0",
  "permissions": ["storage"],
  "action": { "default_title": "ID Watermark" },
  "background": { "service_worker": "background.ts" },
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.tsx"]
  }]
}

That is the manifest WXT generates from a simple config.

Project Structure

extension/
├── src/
│   ├── entrypoints/
│   │   ├── background.ts      # Service worker
│   │   └── content.tsx         # Content script (React app)
│   ├── components/
│   ├── lib/
│   └── assets/
├── wxt.config.ts
└── package.json

The key insight: WXT treats entrypoints/ like Next.js treats pages/. Each file becomes a separate entry point in the extension.

Content Script with Shadow DOM

The extension injects a floating panel into any webpage. The tricky part is CSS isolation — you do not want your extension's styles leaking into the host page.

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

export default defineContentScript({
  matches: ['<all_urls>'],
  main(ctx) {
    const container = document.createElement('div');
    container.id = 'id-watermark-root';
    
    const shadow = container.attachShadow({ mode: 'open' });
    document.body.appendChild(container);
    
    const root = ReactDOM.createRoot(shadow);
    root.render(<App />);
  },
});

Shadow DOM with mode: 'open' keeps styles isolated while still allowing the host page to interact with the panel if needed.

One gotcha: Tailwind CSS inside Shadow DOM requires the styles to be injected into the shadow root. WXT handles this automatically when you use its defineContentScript wrapper.

Background Worker for License Validation

The extension communicates with a web API for PRO license validation. All network calls go through the background worker to avoid CORS issues in content scripts.

// background.ts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'VALIDATE_LICENSE') {
    fetch('https://your-api.com/validate', {
      method: 'POST',
      body: JSON.stringify({ key: message.key }),
    })
      .then(res => res.json())
      .then(data => sendResponse(data))
      .catch(() => sendResponse({ valid: false }));
    return true; // keep channel open for async response
  }
});

Development Workflow

WXT's dev mode is surprisingly smooth:

pnpm wxt

This opens a Chrome instance with the extension loaded and hot reload enabled. Edit a component — the panel refreshes instantly. No manual reload, no rebuild.

For production:

pnpm wxt build

Output goes to .output/chrome-mv3/ as a loadable extension directory.

Lessons Learned

  1. Shadow DOM is mandatory — without it, your extension's Tailwind styles will mess up the host page and vice versa
  2. Background workers are async — always return true from onMessage if you are using fetch
  3. WXT handles the hard parts — manifest, HMR, build, CSS scoping. I spent zero time on tooling

Try the web version at id-watermark.902077.xyz.

$ echo "EOF"

评论交流