WXT
WXT provides the best developer experience, making it quick, easy, and fun to develop web extensions. With built-in utilities for building, zipping, and publishing your extension, it's easy to get started.
url: / title: Next-gen Web Extension Framework
WXTNext-gen Web Extension Framework
An open source tool that makes web extension development faster than ever before.
✅MV2 and MV3Build Manifest V2 or V3 extensions for any browser using the same codebase.Read docs
⚡
Fast Dev Mode
Lightning fast HMR for UI development and fast reloads for content/background scripts enables faster iterations.
🚔
TypeScript
Create large projects with confidence using TS by default.
🦾Auto-importsNuxt-like auto-imports to speed up development.Read docs
🤖
Automated Publishing
Automatically zip, upload, submit, and publish extensions.
🎨Frontend Framework AgnosticWorks with any front-end framework with a Vite plugin.Add a framework
📦Module SystemReuse build-time and runtime-code across multiple extensions.Read docs
🖍️Bootstrap a New ProjectGet started quickly with several awesome project templates.See templates
📏
Bundle Analysis
Tools for analyzing the final extension bundle and minimizing your extension's size.
⬇️Bundle Remote CodeDownloads and bundles remote code imported from URLs.Read docs
Are you an LLM? View /llms.txt for optimized Markdown documentation, or /llms-full.txt for full documentation bundle
Sponsors
WXT is a MIT-licensed open source project with its ongoing development made possible entirely by the support of these awesome backers. If you'd like to join them, please consider sponsoring WXT's development.
Put Developer Experience First
WXT simplifies the web extension development process by providing tools for zipping and publishing, the best-in-class dev mode, an opinionated project structure, and more. Iterate faster, develop features not build scripts, and use everything the JS ecosystem has to offer.
And who doesn't appreciate a beautiful CLI?
Who's Using WXT?
Battle tested and ready for production. Explore web extensions made with WXT.
url: /guide/introduction.html title: Welcome to WXT
Are you an LLM? You can read better optimized documentation at /guide/introduction.md for this page in Markdown format
Welcome to WXT
WXT is a modern, open-source framework for building web extensions. Inspired by Nuxt, its goals are to:
- Provide an awesome DX
- Provide first-class support for all major browsers
Check out the comparison to see how WXT compares to other tools for building web extensions.
Prerequisites
These docs assume you have a basic knowledge of how web extensions are structured and how you access the extension APIs.
:::warning New to extension development? If you have never written an extension before, follow Chrome's Hello World tutorial to first create an extension without WXT, then come back here. :::
You should also be aware of Chrome's extension docs and Mozilla's extension docs. WXT does not change how you use the extension APIs, and you'll need to refer to these docs often when using specific APIs.
Alright, got a basic understanding of how web extensions are structured? Do you know how to access the extension APIs? Then continue to the Installation page to create your first WXT extension.
url: /guide/installation.html title: Installation
Are you an LLM? You can read better optimized documentation at /guide/installation.md for this page in Markdown format
Installation
Bootstrap a new project, start from scratch, or migrate an existing project.
Bootstrap Project
Run the init command, and follow the instructions.
:::code-group
pnpm dlx wxt@latest init
bunx wxt@latest init
npx wxt@latest init
# Use NPM initially, but select Yarn when prompted
npx wxt@latest init
:::
:::info Starter Templates:
Vanilla
Vue
React
Svelte
Solid
All templates use TypeScript by default. To use JavaScript, change the file extensions. :::
Demo

Once you've run the dev command, continue to Next Steps!
From Scratch
- Create a new project
:::code-group
cd my-project
pnpm init
cd my-project
bun init
cd my-project
npm init
cd my-project
yarn init
:::
2. Install WXT:
:::code-group
pnpm i -D wxt
bun add -D wxt
npm i -D wxt
yarn add --dev wxt
:::
3. Add an entrypoint, my-project/entrypoints/background.ts:
:::code-group
export default defineBackground(() => {
console.log('Hello world!');
});
:::
4. Add scripts to your package.json:
package.json
{
"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"
}
}
- Run your extension in dev mode
:::code-group
pnpm dev
bun run dev
npm run dev
yarn dev
:::
WXT will automatically open a browser window with your extension installed.
Next Steps
- Keep reading on about WXT's Project Structure and other essential concepts to learn
- Configure automatic browser startup during dev mode
- Explore WXT's example library to see how to use specific APIs or perform common tasks
- Checkout the community page for a list of resources made by the community!
url: /guide/essentials/project-structure.html title: Project Structure
Are you an LLM? You can read better optimized documentation at /guide/essentials/project-structure.md for this page in Markdown format
Project Structure
WXT follows a strict project structure. By default, it's a flat folder structure that looks like this:
📂 {rootDir}/
📁 .output/
📁 .wxt/
📁 assets/
📁 components/
📁 composables/
📁 entrypoints/
📁 hooks/
📁 modules/
📁 public/
📁 utils/
📄 .env
📄 .env.publish
📄 app.config.ts
📄 package.json
📄 tsconfig.json
📄 web-ext.config.ts
📄 wxt.config.ts
Here's a brief summary of each of these files and directories:
.output/: All build artifacts will go here.wxt/: Generated by WXT, it contains TS configassets/: Contains all CSS, images, and other assets that should be processed by WXTcomponents/: Auto-imported by default, contains UI componentscomposables/: Auto-imported by default, contains source code for your project's composable functions for Vueentrypoints/: Contains all the entrypoints that get bundled into your extensionhooks/: Auto-imported by default, contains source code for your project's hooks for React and Solidmodules/: Contains local WXT Modules for your projectpublic/: Contains any files you want to copy into the output folder as-is, without being processed by WXTutils/: Auto-imported by default, contains generic utilities used throughout your project.env: Contains Environment Variables.env.publish: Contains Environment Variables for publishingapp.config.ts: Contains Runtime Configpackage.json: The standard file used by your package managertsconfig.json: Config telling TypeScript how to behaveweb-ext.config.ts: Configure Browser Startupwxt.config.ts: The main config file for WXT projects
Adding a src/ Directory
Many developers like having a src/ directory to separate source code from configuration files. You can enable it inside the wxt.config.ts file:
wxt.config.ts
export default defineConfig({
srcDir: 'src',
});
After enabling it, your project structure should look like this:
📂 {rootDir}/
📁 .output/
📁 .wxt/
📁 modules/
📁 public/
📂 src/
📁 assets/
📁 components/
📁 composables/
📁 entrypoints/
📁 hooks/
📁 utils/
📄 app.config.ts
📄 .env
📄 .env.publish
📄 package.json
📄 tsconfig.json
📄 web-ext.config.ts
📄 wxt.config.ts
Customizing Other Directories
You can configure the following directories:
wxt.config.ts
export default defineConfig({
// Relative to project root
srcDir: "src", // default: "."
modulesDir: "wxt-modules", // default: "modules"
outDir: "dist", // default: ".output"
publicDir: "static", // default: "public"
// Relative to srcDir
entrypointsDir: "entries", // default: "entrypoints"
})
You can use absolute or relative paths.
url: /guide/essentials/entrypoints.html title: Entrypoints
Are you an LLM? You can read better optimized documentation at /guide/essentials/entrypoints.md for this page in Markdown format
Entrypoints
WXT uses the files inside the entrypoints/ directory as inputs when bundling your extension. They can be HTML, JS, CSS, or any variant of those file types supported by Vite (TS, JSX, SCSS, etc).
Folder Structure
Inside the entrypoints/ directory, an entrypoint is defined as a single file or directory (with an index file) inside it.
:::code-group
📂 entrypoints/
📄 {name}.{ext}
📂 entrypoints/
📂 {name}/
📄 index.{ext}
:::
The entrypoint's name dictates the type of entrypoint. For example, to add a "Background" entrypoint, either of these files would work:
:::code-group
📂 entrypoints/
📄 background.ts
📂 entrypoints/
📂 background/
📄 index.ts
:::
Refer to the Entrypoint Types section for the full list of listed entrypoints and their filename patterns.
Including Other Files
When using an entrypoint directory, entrypoints/{name}/index.{ext}, you can add related files next to the index file.
📂 entrypoints/
📂 popup/
📄 index.html ← This file is the entrypoint
📄 main.ts
📄 style.css
📂 background/
📄 index.ts ← This file is the entrypoint
📄 alarms.ts
📄 messaging.ts
📂 youtube.content/
📄 index.ts ← This file is the entrypoint
📄 style.css
:::danger
DO NOT put files related to an entrypoint directly inside the entrypoints/ directory. WXT will treat them as entrypoints and try to build them, usually resulting in an error.
Instead, use a directory for that entrypoint:
📂 entrypoints/
📄 popup.html
📄 popup.ts
📄 popup.css
📂 popup/
📄 index.html
📄 main.ts
📄 style.css
:::
Deeply Nested Entrypoints
While the entrypoints/ directory might resemble the pages/ directory of other web frameworks, like Nuxt or Next.js, it does not support deeply nesting entrypoints in the same way.
Entrypoints must be zero or one levels deep for WXT to discover and build them:
📂 entrypoints/
📂 youtube/
📂 content/
📄 index.ts
📄 ...
📂 injected/
📄 index.ts
📄 ...
📂 youtube.content/
📄 index.ts
📄 ...
📂 youtube-injected/
📄 index.ts
📄 ...
Unlisted Entrypoints
In web extensions, there are two types of entrypoints:
- Listed: Referenced in the
manifest.json - Unlisted: Not referenced in the
manifest.json
Throughout the rest of WXT's documentation, listed entrypoints are referred to by name. For example:
- Popup
- Options
- Background
- Content Script
However, not all entrypoints in web extensions are listed in the manifest. Some are not listed in the manifest, but are still used by extensions. For example:
- A welcome page shown in a new tab when the extension is installed
- JS files injected by content scripts into the main world
For more details on how to add unlisted entrypoints, see:
Defining Manifest Options
Most listed entrypoints have options that need to be added to the manifest.json. However with WXT, instead of defining the options in a separate file, you define these options inside the entrypoint file itself.
For example, here's how to define matches for content scripts:
entrypoints/content.ts
export default defineContentScript({
matches: ['*://*.wxt.dev/*'],
main() {
// ...
},
});
For HTML entrypoints, options are configured as <meta> tags. For example, to use a page_action for your MV2 popup:
<!doctype html>
<html lang="en">
<head>
<meta name="manifest.type" content="page_action" />
</head>
</html>
Refer to the Entrypoint Types sections for a list of options configurable inside each entrypoint, and how to define them.
When building your extension, WXT will look at the options defined in your entrypoints, and generate the manifest accordingly.
Entrypoint Types
Background
| Filename | Output Path |
|---|---|
| entrypoints/background.[jt]s | /background.js |
| entrypoints/background/index.[jt]s | /background.js |
:::code-group
export default defineBackground(() => {
// Executed when background is loaded
});
export default defineBackground({
// Set manifest options
persistent: undefined | true | false,
type: undefined | 'module',
// Set include/exclude if the background should be removed from some builds
include: undefined | string[],
exclude: undefined | string[],
main() {
// Executed when background is loaded, CANNOT BE ASYNC
},
});
:::
For MV2, the background is added as a script to the background page. For MV3, the background becomes a service worker.
When defining your background entrypoint, keep in mind that WXT will import this file in a NodeJS environment during the build process. That means you cannot place any runtime code outside the main function.
browser.action.onClicked.addListener(() => {
// ...
});
export default defineBackground(() => {
browser.action.onClicked.addListener(() => {
// ...
});
});
Refer to the Entrypoint Loaders documentation for more details.
Bookmarks
| Filename | Output Path |
|---|---|
| entrypoints/bookmarks.html | /bookmarks.html |
| entrypoints/bookmarks/index.html | /bookmarks.html |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
</body>
</html>
When you define a Bookmarks entrypoint, WXT will automatically update the manifest to override the browser's bookmarks page with your own HTML page.
Content Scripts
| Filename | Output Path |
|---|---|
| entrypoints/content.[jt]sx? | /content-scripts/content.js |
| entrypoints/content/index.[jt]sx? | /content-scripts/content.js |
| entrypoints/{name}.content.[jt]sx? | /content-scripts/{name}.js |
| entrypoints/{name}.content/index.[jt]sx? | /content-scripts/{name}.js |
export default defineContentScript({
// Set manifest options
matches: string[],
excludeMatches: undefined | [],
includeGlobs: undefined | [],
excludeGlobs: undefined | [],
allFrames: undefined | true | false,
runAt: undefined | 'document_start' | 'document_end' | 'document_idle',
matchAboutBlank: undefined | true | false,
matchOriginAsFallback: undefined | true | false,
world: undefined | 'ISOLATED' | 'MAIN',
// Set include/exclude if the background should be removed from some builds
include: undefined | string[],
exclude: undefined | string[],
// Configure how CSS is injected onto the page
cssInjectionMode: undefined | "manifest" | "manual" | "ui",
// Configure how/when content script will be registered
registration: undefined | "manifest" | "runtime",
main(ctx: ContentScriptContext) {
// Executed when content script is loaded, can be async
},
});
When defining content script entrypoints, keep in mind that WXT will import this file in a NodeJS environment during the build process. That means you cannot place any runtime code outside the main function.
const container = document.createElement('div');
document.body.append(container);
export default defineContentScript({
main: function () {
const container = document.createElement('div');
document.body.append(container);
},
});
Refer to the Entrypoint Loaders documentation for more details.
See Content Script UI for more info on creating UIs and including CSS in content scripts.
Devtools
| Filename | Output Path |
|---|---|
| entrypoints/devtools.html | /devtools.html |
| entrypoints/devtools/index.html | /devtools.html |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
</body>
</html>
Follow the Devtools Example to add different panels and panes.
History
| Filename | Output Path |
|---|---|
| entrypoints/history.html | /history.html |
| entrypoints/history/index.html | /history.html |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
</body>
</html>
When you define a History entrypoint, WXT will automatically update the manifest to override the browser's history page with your own HTML page.
Newtab
| Filename | Output Path |
|---|---|
| entrypoints/newtab.html | /newtab.html |
| entrypoints/newtab/index.html | /newtab.html |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
</body>
</html>
When you define a Newtab entrypoint, WXT will automatically update the manifest to override the browser's new tab page with your own HTML page.
Options
| Filename | Output Path |
|---|---|
| entrypoints/options.html | /options.html |
| entrypoints/options/index.html | /options.html |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Options Title</title>
<!-- Customize the manifest options -->
<meta name="manifest.open_in_tab" content="true|false" />
<meta name="manifest.chrome_style" content="true|false" />
<meta name="manifest.browser_style" content="true|false" />
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
</body>
</html>
Popup
| Filename | Output Path |
|---|---|
| entrypoints/popup.html | /popup.html |
| entrypoints/popup/index.html | /popup.html |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Set the `action.default_title` in the manifest -->
<title>Default Popup Title</title>
<!-- Customize the manifest options -->
<meta
name="manifest.default_icon"
content="{
'16': '/icon-16.png',
'24': '/icon-24.png',
...
}"
/>
<meta name="manifest.type" content="page_action|browser_action" />
<meta name="manifest.browser_style" content="true|false" />
<!-- Firefox only: where to place the action button -->
<meta
name="manifest.default_area"
content="navbar|menupanel|tabstrip|personaltoolbar"
/>
<!-- Firefox only: icons for light/dark themes -->
<meta
name="manifest.theme_icons"
content="[
{ light: '/icon-light-16.png', dark: '/icon-dark-16.png', size: 16 },
{ light: '/icon-light-32.png', dark: '/icon-dark-32.png', size: 32 }
]"
/>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
</body>
</html>
Sandbox
:::warning Chromium Only Firefox does not support sandboxed pages. :::
| Filename | Output Path |
|---|---|
| entrypoints/sandbox.html | /sandbox.html |
| entrypoints/sandbox/index.html | /sandbox.html |
| entrypoints/{name}.sandbox.html | /{name}.html |
| entrypoints/{name}.sandbox/index.html | /{name}.html |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
</body>
</html>
Side Panel
| Filename | Output Path |
|---|---|
| entrypoints/sidepanel.html | /sidepanel.html |
| entrypoints/sidepanel/index.html | /sidepanel.html |
| entrypoints/{name}.sidepanel.html | /{name}.html` |
| entrypoints/{name}.sidepanel/index.html | /{name}.html` |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Side Panel Title</title>
<!-- Customize the manifest options -->
<meta
name="manifest.default_icon"
content="{
'16': '/icon-16.png',
'24': '/icon-24.png',
...
}"
/>
<meta name="manifest.open_at_install" content="true|false" />
<meta name="manifest.browser_style" content="true|false" />
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
</body>
</html>
In Chrome, side panels use the side_panel API, while Firefox uses the sidebar_action API.
Unlisted CSS
| Filename | Output Path |
|---|---|
| entrypoints/{name}.(css|scss | sass |
| entrypoints/{name}/index.(css|scss | sass |
| entrypoints/content.(css|scss | sass |
| entrypoints/content/index.(css|scss | sass |
| entrypoints/{name}.content.(css|scss | sass |
| entrypoints/{name}.content/index.(css|scss | sass |
body {
/* ... */
}
Follow Vite's guide to setup your preprocessor of choice: https://vitejs.dev/guide/features.html#css-pre-processors
CSS entrypoints are always unlisted. To add CSS to a content script, see the Content Script docs.
Unlisted Pages
| Filename | Output Path |
|---|---|
| entrypoints/{name}.html | /{name}.html |
| entrypoints/{name}/index.html | /{name}.html |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
</body>
</html>
At runtime, unlisted pages are accessible at /{name}.html:
const url = browser.runtime.getURL('/{name}.html');
console.log(url); // "chrome-extension://{id}/{name}.html"
window.open(url); // Open the page in a new tab
Unlisted Scripts
| Filename | Output Path |
|---|---|
| entrypoints/{name}.[jt]sx? | /{name}.js |
| entrypoints/{name}/index.[jt]sx? | /{name}.js |
:::code-group
export default defineUnlistedScript(() => {
// Executed when script is loaded
});
export default defineUnlistedScript({
// Set include/exclude if the script should be removed from some builds
include: undefined | string[],
exclude: undefined | string[],
main() {
// Executed when script is loaded
},
});
:::
At runtime, unlisted scripts are accessible from /{name}.js:
const url = browser.runtime.getURL('/{name}.js');
console.log(url); // "chrome-extension://{id}/{name}.js"
You are responsible for loading/running these scripts where needed. If necessary, don't forget to add the script and/or any related assets to web_accessible_resources.
When defining an unlisted script, keep in mind that WXT will import this file in a NodeJS environment during the build process. That means you cannot place any runtime code outside the main function.
document.querySelectorAll('a').forEach((anchor) => {
// ...
});
export default defineUnlistedScript(() => {
document.querySelectorAll('a').forEach((anchor) => {
// ...
});
});
Refer to the Entrypoint Loaders documentation for more details.
url: /guide/essentials/config/manifest.html title: Manifest
Are you an LLM? You can read better optimized documentation at /guide/essentials/config/manifest.md for this page in Markdown format
Manifest
In WXT, there is no manifest.json file in your source code. Instead, WXT generates the manifest from multiple sources:
- Global options defined in your wxt.config.ts file
- Entrypoint-specific options defined in your entrypoints
- WXT Modules added to your project can modify your manifest
- Hooks defined in your project can modify your manifest
Your extension's manifest.json will be output to .output/{target}/manifest.json when running wxt build.
Global Options
To add a property to your manifest, use the manifest config inside your wxt.config.ts:
export default defineConfig({
manifest: {
// Put manual changes here
},
});
You can also define the manifest as a function, and use JS to generate it based on the target browser, mode, and more.
export default defineConfig({
manifest: ({ browser, manifestVersion, mode, command }) => {
return {
// ...
};
},
});
MV2 and MV3 Compatibility
When adding properties to the manifest, always define the property in it's MV3 format when possible. When targeting MV2, WXT will automatically convert these properties to their MV2 format.
For example, for this config:
export default defineConfig({
manifest: {
action: {
default_title: 'Some Title',
},
web_accessible_resources: [
{
matches: ['*://*.google.com/*'],
resources: ['icon/*.png'],
},
],
},
});
WXT will generate the following manifests:
:::code-group
{
"manifest_version": 2,
// ...
"browser_action": {
"default_title": "Some Title"
},
"web_accessible_resources": ["icon/*.png"]
}
{
"manifest_version": 3,
// ...
"action": {
"default_title": "Some Title"
},
"web_accessible_resources": [
{
"matches": ["*://*.google.com/*"],
"resources": ["icon/*.png"]
}
]
}
:::
You can also specify properties specific to a single manifest version, and they will be stripped out when targeting the other manifest version.
Name
If not provided via the manifest config, the manifest's name property defaults to your package.json's name property.
Version and Version Name
Your extension's version and version_name is based on the version from your package.json.
version_nameis the exact string listedversionis the string cleaned up, with any invalid suffixes removed
Example:
// package.json
{
"version": "1.3.0-alpha2"
}
// .output/<target>/manifest.json
{
"version": "1.3.0",
"version_name": "1.3.0-alpha2"
}
If a version is not present in your package.json, it defaults to "0.0.0".
Icons
WXT automatically discovers your extension's icon by looking at files in the public/ directory:
public/
├─ icon-16.png
├─ icon-24.png
├─ icon-48.png
├─ icon-96.png
└─ icon-128.png
Specifically, an icon must match one of these regex to be discovered:
const iconRegex = [
/^icon-([0-9]+)\.png$/, // icon-16.png
/^icon-([0-9]+)x[0-9]+\.png$/, // icon-16x16.png
/^icon@([0-9]+)w\.png$/, // icon@16w.png
/^icon@([0-9]+)h\.png$/, // icon@16h.png
/^icon@([0-9]+)\.png$/, // icon@16.png
/^icons?[/\\]([0-9]+)\.png$/, // icon/16.png | icons/16.png
/^icons?[/\\]([0-9]+)x[0-9]+\.png$/, // icon/16x16.png | icons/16x16.png
];
If you don't like these filename or you're migrating to WXT and don't want to rename the files, you can manually specify an icon in your manifest:
export default defineConfig({
manifest: {
icons: {
16: '/extension-icon-16.png',
24: '/extension-icon-24.png',
48: '/extension-icon-48.png',
96: '/extension-icon-96.png',
128: '/extension-icon-128.png',
},
},
});
Alternatively, you can use @wxt-dev/auto-icons to let WXT generate your icon at the required sizes.
Firefox theme_icons
Firefox supports a theme_icons field on the toolbar action that swaps between a light and dark variant based on the current browser theme.
When targeting Firefox, WXT auto-discovers paired light/dark icons in the public/ directory and attaches them to action (MV3) or browser_action (MV2). You only need to drop both variants next to your regular icons:
public/
├─ icon-16.png # regular (default_icon)
├─ icon-light-16.png # Firefox light theme
├─ icon-dark-16.png # Firefox dark theme
├─ icon-32.png
├─ icon-light-32.png
└─ icon-dark-32.png
A size is only included in theme_icons if both a light and a dark file are present. The following filename patterns are discovered:
const themeIconRegex = [
/^icon-(?<variant>light|dark)-(?<size>[0-9]+)\.png$/, // icon-light-16.png
/^icon-(?<variant>light|dark)-(?<size>[0-9]+)x[0-9]+\.png$/, // icon-light-16x16.png
/^icon-(?<size>[0-9]+)-(?<variant>light|dark)\.png$/, // icon-16-light.png
/^icon-(?<size>[0-9]+)x[0-9]+-(?<variant>light|dark)\.png$/, // icon-16x16-light.png
/^icons?[/\\](?<variant>light|dark)-(?<size>[0-9]+)\.png$/, // icon/light-16.png | icons/light-16.png
/^icons?[/\\](?<variant>light|dark)-(?<size>[0-9]+)x[0-9]+\.png$/, // icon/light-16x16.png
/^icons?[/\\](?<size>[0-9]+)-(?<variant>light|dark)\.png$/, // icon/16-light.png
/^icons?[/\\](?<size>[0-9]+)x[0-9]+-(?<variant>light|dark)\.png$/, // icon/16x16-light.png
];
Only .png files are discovered today, even though Firefox supports .svg - follow #1120 for updates.
If you set manifest.action.theme_icons (or manifest.browser_action.theme_icons) explicitly in wxt.config.ts, WXT will not overwrite it.
Permissions
Most of the time, you need to manually add permissions to your manifest. Only in a few specific situations are permissions added automatically:
- During development: the
tabsandscriptingpermissions will be added to enable hot reloading. - When a
sidepanelentrypoint is present: Thesidepanelpermission is added.
export default defineConfig({
manifest: {
permissions: ['storage', 'tabs'],
},
});
:::warning Different browsers support different permissions. You are responsible for passing only the permissions required for each browser:
export default defineConfig({
manifest: ({ browser }) => ({
permissions:
browser === 'chrome'
? ['storage', 'favicon', 'declarativeNetRequest']
: ['storage', 'webRequest'],
}),
});
:::
Host Permissions
export default defineConfig({
manifest: {
host_permissions: ['https://www.google.com/*'],
},
});
:::warning If you use host permissions and target both MV2 and MV3, make sure to only include the required host permissions for each version:
export default defineConfig({
manifest: ({ manifestVersion }) => ({
host_permissions: manifestVersion === 2 ? [...] : [...],
}),
});
:::
Default Locale
export default defineConfig({
manifest: {
name: '__MSG_extName__',
description: '__MSG_extDescription__',
default_locale: 'en',
},
});
See I18n docs for a full guide on internationalizing your extension.
Actions
In MV2, you have two options: browser_action and page_action. In MV3, they were merged into a single action API.
By default, whenever an action is generated, WXT falls back to browser_action when targeting MV2.
Action With Popup
To generate a manifest where a UI appears after clicking the icon, just create a Popup entrypoint. If you want to use a page_action for MV2, add the following meta tag to the HTML document's head:
<meta name="manifest.type" content="page_action" />
Action Without Popup
If you want to use the activeTab permission or the browser.action.onClicked event, but don't want to show a popup:
- Delete the Popup entrypoint if it exists
- Add the
actionkey to your manifest:
export default defineConfig({
manifest: {
action: {},
},
});
Same as an action with a popup, WXT will fallback on using browser_action for MV2. To use a page_action instead, add that key as well:
export default defineConfig({
manifest: {
action: {},
page_action: {},
},
});
url: /guide/essentials/config/browser-startup.html title: Browser Startup
Are you an LLM? You can read better optimized documentation at /guide/essentials/config/browser-startup.md for this page in Markdown format
Browser Startup
See the API Reference for a full list of config.
During development, WXT uses web-ext by Mozilla to automatically open a browser window with your extension installed.
Config Files
You can configure browser startup in 3 places:
<rootDir>/web-ext.config.ts: Ignored from version control, this file lets you configure your own options for a specific project without affecting other developers
web-ext.config.ts
import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({
// ...
});
<rootDir>/wxt.config.ts: Via the webExt config, included in version control$HOME/web-ext.config.ts: Provide default values for all WXT projects on your computer
Recipes
Set Browser Binaries
To set or customize the browser opened during development:
web-ext.config.ts
import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({
binaries: {
chrome: '/path/to/chrome-beta', // Use Chrome Beta instead of regular Chrome
firefox: 'firefoxdeveloperedition', // Use Firefox Developer Edition instead of regular Firefox
edge: '/path/to/edge', // Open MS Edge when running "wxt -b edge"
},
});
wxt.config.ts
export default defineConfig({
webExt: {
binaries: {
chrome: '/path/to/chrome-beta', // Use Chrome Beta instead of regular Chrome
firefox: 'firefoxdeveloperedition', // Use Firefox Developer Edition instead of regular Firefox
edge: '/path/to/edge', // Open MS Edge when running "wxt -b edge"
},
},
});
By default, WXT will try to automatically discover where Chrome/Firefox are installed. However, if you have chrome installed in a non-standard location, you need to set it manually as shown above.
Persist Data
By default, to keep from modifying your browser's existing profiles, web-ext creates a brand new profile every time you run the dev script.
Right now, Chromium based browsers are the only browsers that support overriding this behavior and persisting data when running the dev script multiple times.
To persist data, set the --user-data-dir flag in any of the config files mentioned above:
:::code-group
import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({
chromiumArgs: ['--user-data-dir=./.wxt/chrome-data'],
});
import { resolve } from 'node:path';
import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({
// On Windows, the path must be absolute
chromiumProfile: resolve('.wxt/chrome-data'),
keepProfileChanges: true,
});
:::
Now, next time you run the dev script, a persistent profile will be created in .wxt/chrome-data/{profile-name}. With a persistent profile, you can install devtools extensions to help with development, allow the browser to remember logins, etc, without worrying about the profile being reset the next time you run the dev script.
:::tip
You can use any directory you'd like for --user-data-dir, the examples above create a persistent profile for each WXT project. To create a profile for all WXT projects, you can put the chrome-data directory inside your user's home directory.
:::
Disable Opening Browser
If you prefer to load the extension into your browser manually, you can disable the auto-open behavior:
web-ext.config.ts
import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({
disabled: true,
});
Enabling Chrome Features
Some APIs are disabled in Chrome during development because of the default flags web-ext uses to launch Chrome, like the Prompt API.
If your extension depends on new features or services, you can enable them via chromiumArgs:
import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({
chromiumArgs: [
// For example, this flag enables the Prompt API
'--disable-features=DisableLoadExtensionCommandLineSwitch',
],
});
There is no comprehensive list of what feature flags enable what APIs and services.
Alternatively, if you can't find a flag that enables a feature you're looking for, disable the opening the browser during development and use your regular chrome profile for development.
url: /guide/essentials/config/auto-imports.html title: Auto-imports
Are you an LLM? You can read better optimized documentation at /guide/essentials/config/auto-imports.md for this page in Markdown format
Auto-imports
WXT uses unimport, the same tool as Nuxt, to setup auto-imports.
export default defineConfig({
// See https://www.npmjs.com/package/unimport#configurations
imports: {
// ...
},
});
By default, WXT automatically sets up auto-imports for all of it's own APIs and some of your project directories:
<srcDir>/components/*<srcDir>/composables/*<srcDir>/hooks/*<srcDir>/utils/*
All named and default exports from files in these directories are available everywhere else in your project without having to import them.
To see the complete list of auto-imported APIs, run wxt prepare and look at your project's .wxt/types/imports-module.d.ts file.
TypeScript
For TypeScript and your editor to recognize auto-imported variables, you need to run the wxt prepare command.
Add this command to your postinstall script so your editor has everything it needs to report type errors after installing dependencies:
// package.json
{
"scripts": {
"postinstall": "wxt prepare",
},
}
ESLint
ESLint doesn't know about the auto-imported variables unless they are explicitly defined in the ESLint's globals. By default, WXT will generate the config if it detects ESLint is installed in your project. If the config isn't generated automatically, you can manually tell WXT to generate it.
:::code-group
export default defineConfig({
imports: {
eslintrc: {
enabled: 9,
},
},
});
export default defineConfig({
imports: {
eslintrc: {
enabled: 8,
},
},
});
:::
Then in your ESLint config, import and use the generated file:
:::code-group
// eslint.config.mjs
import autoImports from './.wxt/eslint-auto-imports.mjs';
export default [
autoImports,
{
// The rest of your config...
},
];
// .eslintrc.mjs
export default {
extends: ['./.wxt/eslintrc-auto-import.json'],
// The rest of your config...
};
:::
Disabling Auto-imports
Not all developers like auto-imports. To disable them, set imports to false.
export default defineConfig({
imports: false,
});
Explicit Imports (#imports)
You can manually import all of WXT's APIs via the #imports module:
import {
createShadowRootUi,
ContentScriptContext,
MatchPattern,
} from '#imports';
To learn more about how the #imports module works, read the related blog post.
If you've disabled auto-imports, you should still use #imports to import all of WXT's APIs from a single place.
url: /guide/essentials/config/environment-variables.html title: Environment Variables
Are you an LLM? You can read better optimized documentation at /guide/essentials/config/environment-variables.md for this page in Markdown format
Environment Variables
Dotenv Files
WXT supports dotenv files the same way as Vite. Create any of the following files:
.env
.env.local
.env.[mode]
.env.[mode].local
.env.[browser]
.env.[browser].local
.env.[mode].[browser]
.env.[mode].[browser].local
And any environment variables listed inside them will be available at runtime:
# .env
WXT_API_KEY=...
await fetch(`/some-api?apiKey=${import.meta.env.WXT_API_KEY}`);
Remember to prefix any environment variables with WXT_ or VITE_, otherwise they won't be available at runtime, as per Vite's convention.
Built-in Environment Variables
WXT provides some custom environment variables based on the current command:
| Usage | Type | Description |
|---|---|---|
| import.meta.env.MANIFEST_VERSION | 2 │ 3 | The target manifest version |
| import.meta.env.BROWSER | string | The target browser |
| import.meta.env.CHROME | boolean | Equivalent to import.meta.env.BROWSER === "chrome" |
| import.meta.env.FIREFOX | boolean | Equivalent to import.meta.env.BROWSER === "firefox" |
| import.meta.env.SAFARI | boolean | Equivalent to import.meta.env.BROWSER === "safari" |
| import.meta.env.EDGE | boolean | Equivalent to import.meta.env.BROWSER === "edge" |
| import.meta.env.OPERA | boolean | Equivalent to import.meta.env.BROWSER === "opera" |
You can set the targetBrowsers option to make the BROWSER variable a more specific type, like "chrome" | "firefox".
You can also access all of Vite's environment variables:
| Usage | Type | Description |
|---|---|---|
| import.meta.env.MODE | string | The mode the extension is running in |
| import.meta.env.PROD | boolean | When NODE_ENV='production' |
| import.meta.env.DEV | boolean | Opposite of import.meta.env.PROD |
:::details Other Vite Environment Variables Vite provides two other environment variables, but they aren't useful in WXT projects:
import.meta.env.BASE_URL: Usebrowser.runtime.getURLinstead.import.meta.env.SSR: Alwaysfalse. :::
Manifest
To use environment variables in the manifest, you need to use the function syntax:
export default defineConfig({
modules: ['@wxt-dev/module-vue'],
manifest: {
oauth2: {
client_id: import.meta.env.WXT_APP_CLIENT_ID
}
}
manifest: () => ({
oauth2: {
client_id: import.meta.env.WXT_APP_CLIENT_ID
}
}),
});
WXT can't load your .env files until after the config file has been loaded. So by using the function syntax for manifest, it defers creating the object until after the .env files are loaded into the process.
Note that Vite's runtime environment variables, like import.meta.env.DEV, will not be defined. Instead, access the mode like this:
export default defineConfig({
manifest: ({ mode }) => {
const isDev = mode === 'development';
console.log('Is development mode:', isDev);
// ...
},
});
url: /guide/essentials/config/runtime.html title: Runtime Config
Are you an LLM? You can read better optimized documentation at /guide/essentials/config/runtime.md for this page in Markdown format
Runtime Config
This API is still a WIP, with more features coming soon!
Define runtime configuration in a single place, <srcDir>/app.config.ts:
import { defineAppConfig } from '#imports';
// Define types for your config
declare module 'wxt/utils/define-app-config' {
export interface WxtAppConfig {
theme?: 'light' | 'dark';
}
}
export default defineAppConfig({
theme: 'dark',
});
:::warning This file is committed to the repo, so don't put any secrets here. Instead, use Environment Variables :::
To access runtime config, WXT provides the getAppConfig function:
import { getAppConfig } from '#imports';
console.log(getAppConfig()); // { theme: "dark" }
Environment Variables in App Config
You can use environment variables in the app.config.ts file.
declare module 'wxt/utils/define-app-config' {
export interface WxtAppConfig {
apiKey?: string;
skipWelcome: boolean;
}
}
export default defineAppConfig({
apiKey: import.meta.env.WXT_API_KEY,
skipWelcome: import.meta.env.WXT_SKIP_WELCOME === 'true',
});
This has several advantages:
- Define all expected environment variables in a single file
- Convert strings to other types, like booleans or arrays
- Provide default values if an environment variable is not provided
url: /guide/essentials/config/vite.html title: Vite
Are you an LLM? You can read better optimized documentation at /guide/essentials/config/vite.md for this page in Markdown format
Vite
WXT uses Vite under the hood to bundle your extension.
This page explains how to customize your project's Vite config. Refer to Vite's documentation to learn more about configuring the bundler.
:::tip In most cases, you shouldn't change Vite's build settings. WXT provides sensible defaults that output a valid extension accepted by all stores when publishing. :::
Change Vite Config
You can change Vite's config via the wxt.config.ts file:
wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
vite: () => ({
// Override config here, same as `defineConfig({ ... })`
// inside vite.config.ts files
}),
});
Add Vite Plugins
To add a plugin, install the NPM package and add it to the Vite config:
wxt.config.ts
import { defineConfig } from 'wxt';
import VueRouter from 'unplugin-vue-router/vite';
export default defineConfig({
vite: () => ({
plugins: [
VueRouter({
/* ... */
}),
],
}),
});
:::warning
Due to the way WXT orchestrates Vite builds, some plugins may not work as expected. For example, vite-plugin-remove-console normally only runs when you build for production (vite build). However, WXT uses a combination of dev server and builds during development, so you need to manually tell it when to run:
wxt.config.ts
import { defineConfig } from 'wxt';
import removeConsole from 'vite-plugin-remove-console';
export default defineConfig({
vite: (configEnv) => ({
plugins:
configEnv.mode === 'production'
? [removeConsole({ includes: ['log'] })]
: [],
}),
});
Search GitHub issues if you run into issues with a specific plugin.
If an issue doesn't exist for your plugin, open a new one. :::
url: /guide/essentials/config/build-mode.html title: Build Modes
Are you an LLM? You can read better optimized documentation at /guide/essentials/config/build-mode.md for this page in Markdown format
Build Modes
Because WXT is powered by Vite, it supports modes in the same way.
When running any dev or build commands, pass the --mode flag:
wxt --mode production
wxt build --mode development
wxt zip --mode testing
By default, --mode is development for the dev command and production for all other commands (build, zip, etc).
Get Mode at Runtime
You can access the current mode in your extension using import.meta.env.MODE:
switch (import.meta.env.MODE) {
case 'development': // ...
case 'production': // ...
// Custom modes specified with --mode
case 'testing': // ...
case 'staging': // ...
// ...
}
url: /guide/essentials/config/typescript.html title: TypeScript Configuration
Are you an LLM? You can read better optimized documentation at /guide/essentials/config/typescript.md for this page in Markdown format
TypeScript Configuration
When you run wxt prepare, WXT generates a base TSConfig file for your project at <rootDir>/.wxt/tsconfig.json.
At a minimum, you need to create a TSConfig in your root directory that looks like this:
// <rootDir>/tsconfig.json
{
"extends": ".wxt/tsconfig.json",
}
Or if you're in a monorepo, you may not want to extend the config. If you don't extend it, you need to add .wxt/wxt.d.ts to the TypeScript project:
/// <reference path="./.wxt/wxt.d.ts" />
Compiler Options
To specify custom compiler options, add them in <rootDir>/tsconfig.json:
// <rootDir>/tsconfig.json
{
"extends": ".wxt/tsconfig.json",
"compilerOptions": {
"jsx": "preserve",
},
}
TSConfig Paths
WXT provides a default set of path aliases.
| Alias | To | Example |
|---|---|---|
| ~~ | /* | import "~~/scripts" |
| @@ | /* | import "@@/scripts" |
| ~ | /* | import { toLowerCase } from "~/utils/strings" |
| @ | /* | import { toLowerCase } from "@/utils/strings" |
To add your own, DO NOT add them to your tsconfig.json! Instead, use the alias option in wxt.config.ts.
This will add your custom aliases to <rootDir>/.wxt/tsconfig.json next time you run wxt prepare. It also adds your alias to the bundler so it can resolve imports.
import { resolve } from 'node:path';
export default defineConfig({
alias: {
// Directory:
testing: resolve('utils/testing'),
// File:
strings: resolve('utils/strings.ts'),
},
});
import { fakeTab } from 'testing/fake-objects';
import { toLowerCase } from 'strings';
url: /guide/essentials/config/hooks.html title: Hooks
Are you an LLM? You can read better optimized documentation at /guide/essentials/config/hooks.md for this page in Markdown format
Hooks
WXT includes a system that lets you hook into the build process and make changes.
Adding Hooks
The easiest way to add a hook is via the wxt.config.ts. Here's an example hook that modifies the manifest.json file before it is written to the output directory:
wxt.config.ts
export default defineConfig({
hooks: {
'build:manifestGenerated': (wxt, manifest) => {
if (wxt.config.mode === 'development') {
manifest.name += ' (DEV)';
}
},
},
});
Most hooks provide the wxt object as the first argument. It contains the resolved config and other info about the current build. The other arguments can be modified by reference to change different parts of the build system.
You can find the list of all available hooks in the API reference.
Putting one-off hooks like this in your config file is simple, but if you find yourself writing lots of hooks, you should extract them into WXT Modules instead.
Execution Order
Because hooks can be defined in multiple places, including WXT Modules, the order which they're executed can matter. Hooks are executed in the following order:
- NPM modules in the order listed in the modules config
- User modules in /modules folder, loaded alphabetically
- Hooks listed in your
wxt.config.ts
To see the order for your project, run wxt prepare --debug flag and search for the "Hook execution order":
⚙ Hook execution order:
⚙ 1. wxt:built-in:unimport
⚙ 2. src/modules/auto-icons.ts
⚙ 3. src/modules/example.ts
⚙ 4. src/modules/i18n.ts
⚙ 5. wxt.config.ts > hooks
Changing execution order is simple:
- Prefix your user modules with a number (lower numbers are loaded first):
📁 modules/
📄 0.my-module.ts
📄 1.another-module.ts
- If you need to run an NPM module after user modules, just make it a user module and prefix the filename with a number!
// modules/2.i18n.ts
export { default } from '@wxt-dev/i18n/module';
url: /guide/essentials/config/entrypoint-loaders.html title: Entrypoint Loaders
Are you an LLM? You can read better optimized documentation at /guide/essentials/config/entrypoint-loaders.md for this page in Markdown format
Entrypoint Loaders
To generate the manifest and other files at build-time, WXT must import each entrypoint to get their options, like content script matches. For HTML files, this is easy. For JS/TS entrypoints, the process is more complicated.
When loading your JS/TS entrypoints, they are imported into a NodeJS environment, not the browser environment that they normally run in. This can lead to issues commonly seen when running browser-only code in a NodeJS environment, like missing global variables.
WXT does several pre-processing steps to try and prevent errors during this process:
- Use
linkedomto make a small set of browser globals (window,document, etc) available. - Use
@webext-core/fake-browserto create a fake version of thechromeandbrowserglobals expected by extensions. - Pre-process the JS/TS code, stripping out the
mainfunction then tree-shaking unused code from the file
However, this process is not perfect. It doesn't setup all the globals found in the browser and the APIs may behave differently. As such, you should avoid using browser or extension APIs outside the main function of your entrypoints! See Entrypoint Limitations for more details.
:::tip
If you're running into errors while importing entrypoints, run wxt prepare --debug to see more details about this process. When debugging, WXT will print out the pre-processed code to help you identify issues.
:::
Once the environment has been polyfilled and your code pre-processed, it's up the entrypoint loader to import your code, extracting the options from the default export.
url: /guide/essentials/extension-apis.html title: Extension APIs
Are you an LLM? You can read better optimized documentation at /guide/essentials/extension-apis.md for this page in Markdown format
Extension APIs
Different browsers provide different global variables for accessing the extension APIs (chrome provides chrome, firefox provides browser, etc).
WXT merges these two into a unified API accessed through the browser variable.
import { browser } from 'wxt/browser';
browser.action.onClicked.addListener(() => {
// ...
});
:::tip
With auto-imports enabled, you don't even need to import this variable from wxt/browser!
:::
The browser variable WXT provides is a simple export of the browser or chrome globals provided by the browser at runtime:
export const browser = globalThis.browser?.runtime?.id
? globalThis.browser
: globalThis.chrome;
This means you can use the promise-style API for both MV2 and MV3, and it will work across all browsers (Chromium, Firefox, Safari, etc).
Accessing Types
All types can be accessed via WXT's Browser namespace:
import { type Browser } from 'wxt/browser';
function handleMessage(message: any, sender: Browser.runtime.MessageSender) {
// ...
}
Using webextension-polyfill
If you want to use the webextension-polyfill when importing browser, you can do so by installing the @wxt-dev/webextension-polyfill package.
See it's Installation Guide to get started.
Feature Detection
Depending on the manifest version, browser, and permissions, some APIs are not available at runtime. If an API is not available, it will be undefined.
:::warning
Types will not help you here. The types WXT provides for browser assume all APIs exist. You are responsible for knowing whether an API is available or not.
:::
To check if an API is available, use feature detection:
if (browser.runtime.onSuspend != null) {
browser.runtime.onSuspend.addListener(() => {
// ...
});
}
Here, optional chaining is your best friend:
browser.runtime.onSuspend?.addListener(() => {
// ...
});
Alternatively, if you're trying to use similar APIs under different names (to support MV2 and MV3), you can do something like this:
(browser.action ?? browser.browser_action).onClicked.addListener(() => {
//
});
Augmenting the Browser Type
WXT's browser types are based on the @types/chrome package. That means some Firefox-specific APIs may not be typed, like browser.sidebarAction. If you want to add types for these APIs, you can augment the browser type to add them yourself:
// <srcDir>/browser-types.d.ts
import '@wxt-dev/browser';
import type { SidebarAction } from 'webextension-polyfill';
declare module '@wxt-dev/browser' {
namespace Browser {
export const sidebarAction: SidebarAction.Static;
}
}
For this to work, you may need to install
@wxt-dev/browseras a direct dependency.pnpm add @wxt-dev/browser
Entrypoint Limitations
Because WXT imports your entrypoint files into a NodeJS, non-extension environment, the chrome/browser variables provided to extensions by the browser will not be available.
To prevent some basic errors, WXT polyfills these globals with the same in-memory, fake implementation it uses for testing: @webext-core/fake-browser. However, not all the APIs have been implemented.
So it is extremely important to NEVER use browser.* extension APIs outside the main function of any JS/TS entrypoints (background, content scripts, and unlisted scripts). If you do, you'll see an error like this:
✖ Command failed after 440 ms
ERROR Browser.action.onClicked.addListener not implemented.
The fix is simple, just move your API usage into the entrypoint's main function:
:::code-group
browser.action.onClicked.addListener(() => {
/* ... */
});
export default defineBackground(() => {
browser.action.onClicked.addListener(() => {
/* ... */
});
});
browser.runtime.onMessage.addListener(() => {
/* ... */
});
export default defineContentScript({
main() {
browser.runtime.onMessage.addListener(() => {
/* ... */
});
},
});
browser.runtime.onMessage.addListener(() => {
/* ... */
});
export default defineUnlistedScript(() => {
browser.runtime.onMessage.addListener(() => {
/* ... */
});
});
:::
Read Entrypoint Loaders for more technical details about this limitation.
url: /guide/essentials/assets.html title: Assets
Are you an LLM? You can read better optimized documentation at /guide/essentials/assets.md for this page in Markdown format
Assets
/assets Directory
Any assets imported or referenced inside the <srcDir>/assets/ directory will be processed by WXT's bundler.
Here's how you access them:
:::code-group
import imageUrl from '~/assets/image.png';
const img = document.createElement('img');
img.src = imageUrl;
<!-- In HTML tags, you must use the relative path --->
<img src="../assets/image.png" />
.bg-image {
background-image: url(~/assets/image.png);
}
<script>
import image from '~/assets/image.png';
</script>
<template>
<img :src="image" />
</template>
import image from '~/assets/image.png';
<img src={image} />;
:::
/public Directory
Files inside <rootDir>/public/ are copied into the output folder as-is, without being processed by WXT's bundler.
Here's how you access them:
:::code-group
import imageUrl from '/image.png';
const img = document.createElement('img');
img.src = imageUrl;
<img src="/image.png" />
.bg-image {
background-image: url(/image.png);
}
<template>
<img src="/image.png" />
</template>
<img src="/image.png" />
:::
:::warning
Assets in the public/ directory are not accessible in content scripts by default. To use a public asset in a content script, you must add it to your manifest's web_accessible_resources array.
:::
Inside Content Scripts
Assets inside content scripts are a little different. By default, when you import an asset, it returns just the path to the asset. This is because Vite assumes you're loading assets from the same hostname.
But, inside content scripts, the hostname is whatever the tab is set to. So if you try to fetch the asset, manually or as an <img>'s src, it will be loaded from the tab's website, not your extension.
To fix this, you need to convert the image to a full URL using browser.runtime.getURL:
entrypoints/content.ts
import iconUrl from '/icon/128.png';
export default defineContentScript({
matches: ['*://*.google.com/*'],
main() {
console.log(iconUrl); // "/icon/128.png"
console.log(browser.runtime.getURL(iconUrl)); // "chrome-extension://<id>/icon/128.png"
},
});
WASM
How a .wasm file is loaded varies greatly between packages, but most follow a basic setup: Use a JS API to load and execute the .wasm file.
For an extension, that means two things:
- The
.wasmfile needs to be present in output folder so it can be loaded. - You must import the JS API to load and initialize the
.wasmfile, usually provided by the NPM package.
For an example, let's say you have a content script needs to parse TS code into AST. We'll use @oxc-parser/wasm to do it!
First, we need to copy the .wasm file to the output directory. We'll do it with a WXT module:
// modules/oxc-parser-wasm.ts
import { resolve } from 'node:path';
export default defineWxtModule((wxt) => {
wxt.hook('build:publicAssets', (_, assets) => {
assets.push({
absoluteSrc: resolve(
'node_modules/@oxc-parser/wasm/web/oxc_parser_wasm_bg.wasm',
),
relativeDest: 'oxc_parser_wasm_bg.wasm',
});
});
});
Run wxt build, and you should see the WASM file copied into your .output/chrome-mv3 folder!
Next, since this is in a content script and we'll be fetching the WASM file over the network to load it, we need to add the file to the web_accessible_resources:
wxt.config.ts
export default defineConfig({
manifest: {
web_accessible_resources: [
{
// We'll use this matches in the content script as well
matches: ['*://*.github.com/*'],
// Use the same path as `relativeDest` from the WXT module
resources: ['/oxc_parser_wasm_bg.wasm'],
},
],
},
});
And finally, we need to load and initialize the .wasm file inside the content script to use it:
entrypoints/content.ts
import initWasm, { parseSync } from '@oxc-parser/wasm';
export default defineContentScript({
matches: '*://*.github.com/*',
async main(ctx) {
if (!location.pathname.endsWith('.ts')) return;
// Get text from GitHub
const code = document.getElementById(
'read-only-cursor-text-area',
)?.textContent;
if (!code) return;
const sourceFilename = document.getElementById('file-name-id')?.textContent;
if (!sourceFilename) return;
// Load the WASM file:
await initWasm({
module_or_path: browser.runtime.getURL('/oxc_parser_wasm_bg.wasm'),
});
// Once loaded, we can use `parseSync`!
const ast = parseSync(code, { sourceFilename });
console.log(ast);
},
});
This code is taken directly from @oxc-parser/wasm docs with one exception: We manually pass in a file path. In a standard NodeJS or web project, the default path works just fine so you don't have to pass anything in. However, extensions are different. You should always explicitly pass in the full URL to the WASM file in your output directory, which is what browser.runtime.getURL returns.
Run your extension, and you should see OXC parse the TS file!
url: /guide/essentials/target-different-browsers.html title: Targeting Different Browsers
Are you an LLM? You can read better optimized documentation at /guide/essentials/target-different-browsers.md for this page in Markdown format
Targeting Different Browsers
When building an extension with WXT, you can create multiple builds of your extension targeting different browsers and manifest versions.
Target a Browser
Use the -b CLI flag to create a separate build of your extension for a specific browser. By default, chrome is targeted.
wxt # same as: wxt -b chrome
wxt -b firefox
wxt -b custom
During development, if you target Firefox, Firefox will open. All other strings open Chrome by default. To customize which browsers open, see Set Browser Binaries.
Additionally, WXT defines several constants you can use at runtime to detect which browser is in use:
if (import.meta.env.BROWSER === 'firefox') {
console.log('Do something only in Firefox builds');
}
if (import.meta.env.FIREFOX) {
// Shorthand, equivalent to the if-statement above
}
Read about Built-in Environment Variables for more details.
Target a Manifest Version
To target specific manifest versions, use the --mv2 or --mv3 CLI flags.
:::tip Default Manifest Version By default, WXT will target MV2 for Safari and Firefox and MV3 for all other browsers. :::
Similar to the browser, you can get the target manifest version at runtime using the built-in environment variable:
if (import.meta.env.MANIFEST_VERSION === 2) {
console.log('Do something only in MV2 builds');
}
Filtering Entrypoints
Every entrypoint can be included or excluded when targeting specific browsers via the include and exclude options.
Here are some examples:
- Content script only built when targeting
firefox:
export default defineContentScript({
include: ['firefox'],
main(ctx) {
// ...
},
});
- HTML file only built for all targets other than
chrome:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
</body>
</html>
Alternatively, you can use the filterEntrypoints config to list all the entrypoints you want to build.
url: /guide/essentials/content-scripts.html title: Content Scripts
Are you an LLM? You can read better optimized documentation at /guide/essentials/content-scripts.md for this page in Markdown format
Content Scripts
To create a content script, see Entrypoint Types.
Context
The first argument to a content script's main function is its "context".
// entrypoints/example.content.ts
export default defineContentScript({
main(ctx) {},
});
This object is responsible for tracking whether or not the content script's context is "invalidated". Most browsers, by default, do not stop content scripts if the extension is uninstalled, updated, or disabled. When this happens, content scripts start reporting this error:
Error: Extension context invalidated.
The ctx object provides several helpers to stop asynchronous code from running once the context is invalidated:
ctx.addEventListener(...);
ctx.setTimeout(...);
ctx.setInterval(...);
ctx.requestAnimationFrame(...);
// and more
You can also check if the context is invalidated manually:
if (ctx.isValid) {
// do something
}
// OR
if (ctx.isInvalid) {
// do something
}
CSS
In regular web extensions, CSS for content scripts is usually a separate CSS file, that is added to a CSS array in the manifest:
{
"content_scripts": [
{
"css": ["content/style.css"],
"js": ["content/index.js"],
"matches": ["*://*/*"]
}
]
}
In WXT, to add CSS to a content script, simply import the CSS file into your JS entrypoint, and WXT will automatically add the bundled CSS output to the css array.
// entrypoints/example.content/index.ts
import './style.css';
export default defineContentScript({
// ...
});
To create a standalone content script that only includes a CSS file:
- Create the CSS file:
entrypoints/example.content.css - Use the
build:manifestGeneratedhook to add the content script to the manifest:
wxt.config.ts
export default defineConfig({
hooks: {
'build:manifestGenerated': (wxt, manifest) => {
manifest.content_scripts ??= [];
manifest.content_scripts.push({
// Build extension once to see where your CSS get's written to
css: ['content-scripts/example.css'],
matches: ['*://*/*'],
});
},
},
});
UI
WXT provides 3 built-in utilities for adding UIs to a page from a content script:
- Integrated -
createIntegratedUi - Shadow Root -
createShadowRootUi - IFrame -
createIframeUi
Each has their own set of advantages and disadvantages.
| Method | Isolated Styles | Isolated Events | HMR | Use page's context |
|---|---|---|---|---|
| Integrated | ❌ | ❌ | ❌ | ✅ |
| Shadow Root | ✅ | ✅ (off by default) | ❌ | ✅ |
| IFrame | ✅ | ✅ | ✅ | ❌ |
Integrated
Integrated content script UIs are injected alongside the content of a page. This means that they are affected by CSS on that page.
:::code-group
// entrypoints/example-ui.content.ts
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Append children to the container
const app = document.createElement('p');
app.textContent = '...';
container.append(app);
},
});
// Call mount to add the UI to the DOM
ui.mount();
},
});
// entrypoints/example-ui.content/index.ts
import { createApp } from 'vue';
import App from './App.vue';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Create the app and mount it to the UI container
const app = createApp(App);
app.mount(container);
return app;
},
onRemove: (app) => {
// Unmount the app when the UI is removed
app.unmount();
},
});
// Call mount to add the UI to the DOM
ui.mount();
},
});
// entrypoints/example-ui.content/index.tsx
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(container);
root.render(<App />);
return root;
},
onRemove: (root) => {
// Unmount the root when the UI is removed
root.unmount();
},
});
// Call mount to add the UI to the DOM
ui.mount();
},
});
// entrypoints/example-ui.content/index.ts
import App from './App.svelte';
import { mount, unmount } from 'svelte';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Create the Svelte app inside the UI container
return mount(App, { target: container });
},
onRemove: (app) => {
// Destroy the app when the UI is removed
unmount(app);
},
});
// Call mount to add the UI to the DOM
ui.mount();
},
});
// entrypoints/example-ui.content/index.ts
import { render } from 'solid-js/web';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Render your app to the UI container
const unmount = render(() => <div>...</div>, container);
return unmount;
},
onRemove: (unmount) => {
// Unmount the app when the UI is removed
unmount();
},
});
// Call mount to add the UI to the DOM
ui.mount();
},
});
:::
See the API Reference for the complete list of options.
Shadow Root
Often in web extensions, you don't want your content script's CSS affecting the page, or vise-versa. The ShadowRoot API is ideal for this.
WXT's createShadowRootUi abstracts all the ShadowRoot setup away, making it easy to create UIs whose styles are isolated from the page. It also supports an optional isolateEvents parameter to further isolate user interactions.
To use createShadowRootUi, follow these steps:
- Import your CSS file at the top of your content script
- Set cssInjectionMode: "ui" inside
defineContentScript - Define your UI with
createShadowRootUi() - Mount the UI so it is visible to users
:::code-group
// 1. Import the style
import './style.css';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
async main(ctx) {
// 3. Define your UI
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: 'body',
onMount(container) {
// Define how your UI will be mounted inside the container
const app = document.createElement('p');
app.textContent = 'Hello world!';
container.append(app);
},
});
// 4. Mount the UI
ui.mount();
},
});
// 1. Import the style
import './style.css';
import { createApp } from 'vue';
import App from './App.vue';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
async main(ctx) {
// 3. Define your UI
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Define how your UI will be mounted inside the container
const app = createApp(App);
app.mount(container);
return app;
},
onRemove: (app) => {
// Unmount the app when the UI is removed
app?.unmount();
},
});
// 4. Mount the UI
ui.mount();
},
});
// 1. Import the style
import './style.css';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
async main(ctx) {
// 3. Define your UI
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Container is a body, and React warns when creating a root on the body, so create a wrapper div
const app = document.createElement('div');
container.append(app);
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(app);
root.render(<App />);
return root;
},
onRemove: (root) => {
// Unmount the root when the UI is removed
root?.unmount();
},
});
// 4. Mount the UI
ui.mount();
},
});
// 1. Import the style
import './style.css';
import App from './App.svelte';
import { mount, unmount } from 'svelte';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
async main(ctx) {
// 3. Define your UI
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Create the Svelte app inside the UI container
return mount(App, { target: container });
},
onRemove: (app) => {
// Destroy the app when the UI is removed
unmount(app);
},
});
// 4. Mount the UI
ui.mount();
},
});
// 1. Import the style
import './style.css';
import { render } from 'solid-js/web';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
async main(ctx) {
// 3. Define your UI
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Render your app to the UI container
const unmount = render(() => <div>...</div>, container);
},
onRemove: (unmount) => {
// Unmount the app when the UI is removed
unmount?.();
},
});
// 4. Mount the UI
ui.mount();
},
});
:::
See the API Reference for the complete list of options.
Full examples:
:::warning rem Units Are Not Fully Isolated
WXT resets most inherited styles via all: initial. This doesn't reset the <html> element's font size, which determines the relative size of rem units.
If your CSS framework uses rem units, like Tailwind CSS, you may notice your UI's scale changing on different websites.
See the FAQ for a fix. :::
IFrame
If you don't need to run your UI in the same frame as the content script, you can use an IFrame to host your UI instead. Since an IFrame just hosts an HTML page, HMR is supported.
WXT provides a helper function, createIframeUi, which simplifies setting up the IFrame.
- Create an HTML page that will be loaded into your IFrame:
<!-- entrypoints/example-iframe.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Your Content Script IFrame</title>
</head>
<body>
<!-- ... -->
</body>
</html>
- Add the page to the manifest's
web_accessible_resources:
wxt.config.ts
export default defineConfig({
manifest: {
web_accessible_resources: [
{
resources: ['example-iframe.html'],
matches: [...],
},
],
},
});
- Create and mount the IFrame:
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
// Define the UI
const ui = createIframeUi(ctx, {
page: '/example-iframe.html',
position: 'inline',
anchor: 'body',
onMount: (wrapper, iframe) => {
// Add styles to the iframe like width
iframe.width = '123';
},
});
// Show UI to user
ui.mount();
},
});
See the API Reference for the complete list of options.
Isolated World vs Main World
By default, all content scripts run in an isolated context where only the DOM is shared with the webpage it is running on - an "isolated world". In MV3, Chromium introduced the ability to run content scripts in the "main" world - where everything, not just the DOM, is available to the content script, just like if the script were loaded by the webpage.
You can enable this for a content script by setting the world option:
export default defineContentScript({
world: 'MAIN',
});
However, this approach has several notable drawbacks:
- Doesn't support MV2
world: "MAIN"is only supported by Chromium browsers- Main world content scripts don't have access to the extension API
Instead, WXT recommends injecting a script into the main world manually using it's injectScript function. This will address the drawbacks mentioned before.
injectScriptsupports both MV2 and MV3injectScriptsupports all browsers- Having a "parent" content script means you can send messages back and forth, making it possible to access the extension API
To use injectScript, we need two entrypoints, one content script and one unlisted script:
📂 entrypoints/
📄 example.content.ts
📄 example-main-world.ts
// entrypoints/example-main-world.ts
export default defineUnlistedScript(() => {
console.log('Hello from the main world');
});
// entrypoints/example.content.ts
export default defineContentScript({
matches: ['*://*/*'],
async main() {
console.log('Injecting script...');
await injectScript('/example-main-world.js', {
keepInDom: true,
});
console.log('Done!');
},
});
export default defineConfig({
manifest: {
// ...
web_accessible_resources: [
{
resources: ["example-main-world.js"],
matches: ["*://*/*"],
}
]
}
});
injectScript works by creating a script element on the page pointing to your script. This loads the script into the page's context so it runs in the main world.
injectScript returns a promise, that when resolved, means the script has been evaluated by the browser and you can start communicating with it.
:::warning Warning: run_at Caveat
For MV3, injectScript is synchronous and the injected script will be evaluated at the same time as your the content script's run_at.
However for MV2, injectScript has to fetch the script's text content and create an inline <script> block. This means for MV2, your script is injected asynchronously and it will not be evaluated at the same time as your content script's run_at.
:::
The script element can be modified just before it is added to the DOM by using the modifyScript option. This can be used to e.g. modify script.async/script.defer, add event listeners to the element, or pass data to the script via script.dataset. An example:
// entrypoints/example.content.ts
export default defineContentScript({
matches: ['*://*/*'],
async main() {
await injectScript('/example-main-world.js', {
modifyScript(script) {
script.dataset['greeting'] = 'Hello there';
},
});
},
});
// entrypoints/example-main-world.ts
export default defineUnlistedScript(() => {
console.log(document.currentScript?.dataset['greeting']);
});
injectScript returns the created script element. It can be used to e.g. send messages to the script in the form of custom events. The script can add an event listener for them via document.currentScript. An example of bidirectional communication:
// entrypoints/example.content.ts
export default defineContentScript({
matches: ['*://*/*'],
async main() {
const { script } = await injectScript('/example-main-world.js', {
modifyScript(script) {
// Add a listener before the injected script is loaded.
script.addEventListener('from-injected-script', (event) => {
if (event instanceof CustomEvent) {
console.log(`${event.type}:`, event.detail);
}
});
},
});
// Send an event after the injected script is loaded.
script.dispatchEvent(
new CustomEvent('from-content-script', {
detail: 'General Kenobi',
}),
);
},
});
// entrypoints/example-main-world.ts
export default defineUnlistedScript(() => {
const script = document.currentScript;
script?.addEventListener('from-content-script', (event) => {
if (event instanceof CustomEvent) {
console.log(`${event.type}:`, event.detail);
}
});
script?.dispatchEvent(
new CustomEvent('from-injected-script', {
detail: 'Hello there',
}),
);
});
Mounting UI to dynamic element
In many cases, you may need to mount a UI to a DOM element that does not exist at the time the web page is initially loaded. To handle this, use the autoMount API to automatically mount the UI when the target element appears dynamically and unmount it when the element disappears. In WXT, the anchor option is used to target the element, enabling automatic mounting and unmounting based on its appearance and removal.
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
// It observes the anchor
anchor: '#your-target-dynamic-element',
onMount: (container) => {
// Append children to the container
const app = document.createElement('p');
app.textContent = '...';
… [truncated — open the raw llms.txt above for the full file]
Meet the modern standard for public facing documentation. Beautiful out of the box, easy to maintain, and optimized for user engagement.
Search through billions of items for similar matches to any object, in milliseconds. It’s the next generation of search, an API call away.
Build and deploy reliable background jobs with no timeouts and no infrastructure to manage.
Get the simple developer experience of SQLite in production, and scale your multi-tenant backend with unlimited databases.
Upstash is a serverless data platform providing low latency and high scalability for real-time applications.
One-click deployments built for teams, tuned for Laravel, loaded with tools and goodies you're going to love.