The Journey of Migrating Our Browser Extension from Plasmo to WXT Framework
Learn why and how we migrated our browser extension (ChatGPT Writer) from Plasmo to WXT Framework, tackling challenges and unlocking smoother developm
Originally written at https://chatgptwriter.ai/blog/migrate-plasmo-to-wxt
I’m excited to share that we've successfully migrated our browser extension (ChatGPT Writer) from Plasmo Framework to WXT Framework. Both browser extension frameworks are top-notch for developing browser extensions, each with their own set of pros and cons. If you're curious about how they stack up against each other, this comparison offers a great overview.
Why We Decided to Migrate from Plasmo to WXT
When OpenAI’s ChatGPT first launched, I decided to build a Chrome extension (in December 2022) that would integrate with Gmail and use ChatGPT to draft email replies. The next task was to choose the right tools, so I started researching the best options. Unlike JS, there weren’t many frameworks for building Chrome extensions. I knew Webpack was an option because I had previously built another browser extension (Notion Boost) using it, but it had its own drawbacks. During my research, I stumbled upon the Plasmo framework, and it felt like the right choice at that time. Plasmo handled code bundling, splitting, minification, and other tasks. It significantly sped up development, and I’m grateful to the authors for developing the library.
While Plasmo has been a fantastic tool for us, it started to feel like it wasn’t the perfect fit anymore. Our browser extension has grown far beyond just drafting replies on Gmail. We noticed that our development progress—and our ability to roll out new features—was being slowed down.
Any change would take more than 7 seconds to rebuild the extension on my M1 Mac. It was even slower on Windows.
We also encountered odd issues where updated code wouldn’t reflect in the extension, and we had to restart the Plasmo server a couple of times to make it work.
We were managing with these issues, but we had to draw the line when we were improving our extension’s UI to add support for Markdown and syntax highlighting and ran into significant hurdles. The issue was Plasmo's use of an outdated Parcel bundler, which caused compatibility issues with Markdown and syntax highlighting packages that require a more up-to-date bundler. When we discussed this concern with the author about updating the Parcel version, we learned that the author is no longer actively working on the framework (which, by the way, I fully understand and respect), so there was little hope the issue would be resolved anytime soon.
At this point, we wanted to rely less on meta-frameworks and initially decided to use only the build tool Vite while writing the browser extension-specific configuration ourselves to minimize dependencies. We had seen great reviews of Vite from various developer communities online, so it seemed like the right choice.
During our research, we also came across WXT, another meta-framework but built on top of Vite. After evaluating the pros and cons of using Vite versus WXT, we decided to go with WXT. Here’s why:
Vite is a general-purpose build tool, and we weren't sure how long it would take to write custom configurations to make it fully functional for browser extensions and to include all the features we had become accustomed to with Plasmo. WXT handles all of that for us, saving a lot of time.
Since WXT is built on top of Vite, it benefits from Vite’s advantages. Additionally, if WXT is no longer maintained in the future, it would require less effort to switch to using Vite directly.
I am happy with our decision to go with WXT at this time. Not only did WXT resolve our existing issues, but we also noticed a much smaller extension build size. When building the extension with Plasmo, the output ZIP file was 700 KB, but with WXT, it’s 400 KB.
If you're interested in the discussions that influenced our decision, you can check out this thread on Github.
Our Migration Process
Migrating any project from one framework to another is rarely straightforward, and our transition from Plasmo to WXT was no exception. While WXT offers a brief migration guide in their documentation (you can check it out here), that are sufficient for only basic extensions.
If you're working on a complex browser extension like ours, you'll likely face a host of challenges that aren't covered in the official guide—from configuration conflicts to managing cross-context communication between background scripts and content scripts.
We're here to share the changes and adjustments we had to make during our migration process. These insights might help you navigate similar issues you could encounter.
1. Organizing Entrypoints in WXT
Differences in Entrypoint Management:
In Plasmo, creating components like content scripts, background scripts, and popup was straightforward—you could simply define them at the root of your project, and they would work as expected.
However, WXT handles entrypoints differently. In WXT, you need to place these files inside a specific directory called entrypoints
to ensure they are recognized and function properly.
What You Might Need to Do:
Create an
entrypoints
Directory: Organize your entrypoint files by creating anentrypoints
directory at the root of your project.Move Your Entrypoint Files: Place your content scripts, background scripts, injected scripts, and popup HTML files into this directory.
Here's how your project structure should look:
<rootDir>
└─ entrypoints/
├─ background.ts
├─ content.ts
├─ injected.ts
└─ popup.html
2. Adding a Post-Install Command for WXT Preparation
Why This is Necessary:
After setting up your entrypoints, WXT requires a .wxt
directory in your project. This directory contains essential type definitions, a tsconfig
file, and other global configurations necessary for your extension to function correctly during development and builds.
What You Might Need to Do:
Add a postinstall
script to your package.json
to automatically generate the .wxt
directory whenever you install dependencies using npm install
. This ensures WXT's configurations are always up-to-date. Here's how to add it:
// In your package.json
{
// ... other configurations
"scripts": {
// ... other scripts
"postinstall": "wxt prepare"
}
}
By including this postinstall
command, you automate the setup process required by WXT, preventing potential issues related to missing configurations during development.
3. Config Adjustments
TypeScript Configuration Conflicts:
During the migration to WXT, you may encounter conflicts arising from TypeScript configurations. WXT introduces its own TypeScript configuration file located inside the .wxt
directory. This can conflict with your existing tsconfig.json
file, leading to type-checking errors, broken import aliases, and other frustrating issues.
What You Might Need to Do:
Instead of overwriting WXT's configuration, extend it within your own .tsconfig
file to allow both configurations to coexist smoothly. Additionally, you'll need to adjust some compiler options to prevent any conflicts and ensure smooth integration. Here's how you can do it:
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
....
}
}
Managing Path Aliases: You might also face path conflicts due to differences in how WXT handles module resolution. We resolved this by following WXT's default path rules, which simplified the process and prevented import issues.
Alternatively, you can define your own custom paths in wxt.config.js
if your project structure requires it. For more details on setting up custom path aliases, refer to this section in the WXT documentation.
4. Environment Variable Issues
Differences in Handling Environment Variables:
WXT uses Vite under the hood, which manages environment variables differently than Plasmo. This means that environment variables that worked in Plasmo might become undefined
after migrating to WXT.
What You Might Need to Do:
You'll need to adapt to Vite’s method of handling environment variables. This involves prefixing your variables with VITE_
and accessing them using import.meta.env
. Here's a quick example:
// Defining an environment variable (using VITE_ as a prefix)
VITE_SOME_KEY=XXX
// Retrieving the value of the environment variable (using import.meta.env)
console.log(import.meta.env.VITE_SOME_KEY);
By making this change, you ensure that your environment variables are correctly recognized in the WXT environment. If you're unfamiliar with Vite's setup, the Vite Environment Variables Guide is a helpful resource.
5. Migrating Manifest Configuration to wxt.config.ts
Differences in Manifest Configuration:
In Plasmo, you might have defined your extension's manifest configuration directly within the package.json
file. However, WXT handles manifest settings differently. In WXT, you need to specify your manifest configuration inside a separate file called wxt.config.ts
.
What You Might Need to Do:
You'll need to migrate your manifest configuration from package.json
to wxt.config.ts
. Here's how you can set it up:
Create a
wxt.config.ts
File: Start by creating a new file namedwxt.config.ts
at the root of your project. This file will serve as the main configuration file for WXT.Migrate Your Manifest Configuration: Move your existing manifest settings from
package.json
into themanifest
field withinwxt.config.ts
.
Here's how you can set it up:
// Inside wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifestVersion: 3,
manifest: {
// ... your manifest configurations
},
});
6. Adjusting Icon Management
Differences in Icon Handling:
In Plasmo, one of the convenient features is that it automatically generates smaller resolution versions of your icon for the build. This means you only need to provide a high-resolution version of your icon, and Plasmo handles the rest. Additionally, in development mode, Plasmo converts the icon to grayscale to help distinguish it from the production bundle.
In contrast, WXT requires you to manually create icon files for each required resolution (e.g., 16×16, 48×48, 128×128). You'll need to place these icons inside the public
directory and explicitly define them in your manifest configuration.
What You Might Need to Do:
Create Icons of Required Sizes: Manually create your extension icons in all the necessary resolutions.
Place Icons in the Public Directory: Store these icon files in your project's
public
directory so they are accessible during the build process.Define Icons in Manifest Configuration: Update your
wxt.config.ts
file to specify the icons in the manifest configuration. Here's how you can set it up: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', }, }, });
We later learned that WXT provides auto-icons plugin that takes care of icons.
7. Handling Inline Asset Imports
Differences in Asset Importing Between Plasmo and WXT:
In Plasmo, when you want to import images or other assets inline, you use the data-base64
scheme. This method inlines the asset as base64-encoded data directly into your extension's bundle.
In WXT, you don't need to use this scheme; you can import assets directly without any special prefixes.
What You Might Need to Do:
Update your import statements by removing the data-base64
prefix. Here's how you can adjust your code:
// Plasmo: Using data-base64 scheme
import Image from "data-base64:~assets/image.png";
// WXT: Importing directly without data-base64
import Image from "~assets/image.png";
8. Resolving Port Conflicts in Development (Optional)
Potential Port Conflicts with Next.js:
If you're using Next.js as your backend server, you might encounter port conflicts during development. Both Next.js and WXT default to using port 3000, which can create issues when you try to run them simultaneously.
In our experience, we had WXT already running on port 3000. When we attempted to start the Next.js server, it also tried to use port 3000. Surprisingly, even though the port was already occupied by WXT, the Next.js server started without any error messages or warnings about the port conflict. However, we couldn't access our Next.js application at localhost:3000
because WXT was occupying that port.
This led to confusion because Next.js didn't notify us of the port being in use, and we were left wondering why our application wasn't accessible. On the other hand, WXT is capable of detecting when a port is already occupied and will automatically switch to another available port, preventing such conflicts.
What You Might Need to Do:
To resolve this port conflict, you have a couple of options:
Assign a Different Port to WXT: You can specify a different port for WXT to use during development. For example, you can start the WXT server on port 9000 by modifying your
package.json
scripts or your start command:"dev": "wxt --mode localhost --port 9000", // it will start the server on port 9000
This way, WXT will run on localhost:9000
, leaving port 3000 free for Next.js. You can then access your Next.js application at localhost:3000
and your WXT extension at localhost:9000
.
- Start Next.js Server Before WXT: Alternatively, you can start your Next.js server before starting WXT. Since WXT can detect an occupied port and automatically switch to another one, it will adjust itself to use a different port if it finds that port 3000 is already in use.
9. Handling Double Rendering in Development
React.StrictMode and Its Effects:
During development, you might notice that components, especially popup, render twice. This is because WXT uses React.StrictMode
by default, which intentionally double-invokes lifecycle methods to help identify potential issues.
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Popup />
</React.StrictMode>,
);
In our case, we had message-sending logic inside a useEffect
hook, and the double rendering caused messages to be sent twice—once for each render. This led to unintended side effects in our extension's behavior during development.
What You Might Need to Do:
Be mindful that side effects (like API calls or message dispatches) inside useEffect
or other lifecycle methods might execute twice. To handle this gracefully:
Optimize Side Effects: Ensure that your side-effect logic can handle multiple invocations without causing unintended consequences.
Conditional Logic: Implement checks to prevent duplicate actions if necessary.
Understand It's a Development-Only Behavior: Remember that this double rendering only occurs in development mode due to
React.StrictMode
. Your production build won't have this issue.
By adjusting your code to account for React.StrictMode
, you can prevent unwanted side effects during development without compromising your production code.
10. Managing Shadow DOM Implementation (Optional)
Differences in Shadow DOM Handling:
Both Plasmo and WXT offer built-in methods for handling Shadow DOM functionality in browser extensions. When migrating to WXT, you have a couple of options for implementing Shadow DOM in your extension.
What You Might Need to Do:
Use WXT's Built-in Shadow DOM Handling: If you prefer to adopt WXT's approach, you can utilize their built-in methods for managing Shadow DOM. WXT provides a straightforward way to implement Shadow DOM. You can find detailed instructions in the WXT documentation: WXT Shadow Root Guide.
Implement a Framework-Independent Shadow DOM: Alternatively, you might choose to implement Shadow DOM in a framework-independent manner, as we did. This approach allows you to maintain consistency in your codebase and avoid adjustments during the migration process. By not relying on framework-specific methods, you make future migrations or updates more straightforward. If you're interested in adopting this method, I've detailed our approach in a Guide: Render React element inside shadow DOM.
11. Avoid Cross-Context Code Sharing (Good to know)
Understanding Context Isolation in Extensions:
Browser extensions run different parts of their code (like background scripts, content scripts, and popup) in separate contexts. Sharing code directly between these contexts can lead to errors because they don't share the same execution environment.
What You Might Need to Do:
Use Messaging APIs for Communication: Instead of sharing functions or variables directly between contexts, use messaging APIs (e.g.,
chrome.runtime.sendMessage
) to facilitate communication.Use a Shared Directory: Organize reusable, context-independent code in a dedicated
shared
directory. This allows you to import common functions or utilities without causing context conflicts.<rootDir> ├─ entrypoints/ │ ├─ background.ts │ ├─ content.ts │ └─ popup.tsx └─ shared/ ├─ utils.ts └─ constants.ts
Avoid Context-Specific Code in Shared Modules: Ensure that the code in the
shared
directory doesn't rely on context-specific APIs likedocument
orwindow
.
Conclusion
Migrating from Plasmo to WXT was a significant step that allowed us to overcome development hurdles and align our extension with our growing needs. While the process had its challenges, we hope that sharing our experiences and solutions will help others navigate their own migrations more smoothly.