Best Chrome Extension Boilerplates in 2026
TL;DR
- Plasmo is the most feature-complete extension framework — file-based routing for all extension surfaces, CSUI for React in content scripts, messaging system, and Chrome Web Store deployment tooling
- WXT is the best Vite-based alternative — faster builds, first-class multi-browser support (Firefox, Edge, Safari), and better tree-shaking than Plasmo
- CRXJS is the right choice for teams already deep in the Vite ecosystem who want HMR in content scripts with minimal abstraction overhead
- MV3 service workers are terminated after 30 seconds of inactivity — never store state in memory; use
chrome.storageinstead - The Chrome Web Store review process takes 3-7 days and has non-obvious rejection traps around the single-purpose policy and privacy disclosures
Chrome Extensions: 3 Billion User Distribution Channel
Chrome extensions are underrated as a distribution channel. The Chrome Web Store reaches every Chrome user — 3 billion people. Extensions that solve a real problem can grow virally without ad spend.
In 2026, Manifest V3 (MV3) is the mandatory standard, having replaced MV2. The MV3 migration changed how extensions work — background scripts became service workers, webRequestBlocking became declarative. Boilerplates now abstract MV3's quirks.
MV3 Migration: What Changed
The Manifest V3 transition was the most disruptive change to Chrome extension development in a decade. Understanding the architectural differences is essential before choosing a boilerplate, because the boilerplate you choose determines how well these quirks are abstracted away.
webRequest vs declarativeNetRequest: The most controversial MV3 change. MV2's webRequest API let extensions intercept, inspect, and modify network requests dynamically in JavaScript. Ad blockers like uBlock Origin relied on this to dynamically apply filter rules. MV3 replaces this with declarativeNetRequest, where you declare a static ruleset in JSON that the browser evaluates natively. The capability difference: you can no longer write JavaScript that runs on every network request. For most extensions (productivity tools, UI enhancers, tab managers), this change is irrelevant. For privacy tools and content blockers, it required significant rearchitecting.
Background pages vs service workers: MV2 background pages were persistent HTML pages running in the background for the lifetime of the browser session. Your background script could store state in variables and trust that state would survive. MV3 service workers behave like fetch event handlers in a Progressive Web App: they start when needed, handle events, and are terminated after 30 seconds of inactivity. This is not a theoretical limitation — it will bite you in production. Common failure modes: a timer set in the service worker doesn't fire because the service worker was terminated, a cache populated on install is gone when the service worker restarts, an in-flight operation is abandoned mid-execution. The fix for all of these is the same: never store state in memory. Use chrome.storage.local or chrome.storage.session as your persistence layer. Any state that needs to survive service worker termination must be written to storage before the service worker becomes idle.
Storage migration: The chrome.storage API has three namespaces in MV3. storage.local persists across sessions and service worker restarts (up to 10MB by default, expandable with unlimitedStorage permission). storage.session persists within a browser session but is cleared when the browser closes, and is accessible from content scripts as well as service workers (useful for passing auth tokens without storing them permanently). storage.sync syncs across the user's Chrome-signed-in devices (up to 100KB, with rate limits). For extension SaaS products where you need to know whether a user is subscribed, store the subscription status in storage.local and refresh it periodically from your API.
Quick Comparison
| Starter | Language | React | TypeScript | Hot Reload | Build Tool | Best For |
|---|---|---|---|---|---|---|
| Plasmo | TS | ✅ | ✅ | ✅ | Parcel | Full-featured React extensions |
| WXT | TS/JS | ✅ | ✅ | ✅ | Vite | Modern Vite-based extensions |
| CRXJS | TS/JS | ✅ | ✅ | ✅ | Vite | Vite extensions (manual setup) |
| chrome-extension-boilerplate-react | TS | ✅ | ✅ | ✅ | Vite | React/Vite extension template |
Plasmo — Best Full-Featured
Price: Free | Creator: Plasmo team
The most feature-complete Chrome extension framework. File-based routing for extension surfaces (popup, options, content scripts, background, newtab), automatic manifest generation, React/Vue/Svelte support, messaging system, storage API wrapper, and CSUI (Content Script UI) that renders React into any webpage.
pnpm create plasmo my-extension
# Choose: React + TypeScript
Plasmo file structure:
├── popup.tsx # → Extension popup
├── options.tsx # → Options page
├── newtab.tsx # → New tab page
├── background.ts # → Service worker
└── contents/
├── overlay.tsx # → Content script with React UI
└── inline.tsx # → Inline content script
Plasmo CSUI — Inject React into any page:
// contents/sidebar.tsx
import cssText from "data-text:./sidebar.css"
import type { PlasmoCSConfig } from "plasmo"
export const config: PlasmoCSConfig = {
matches: ["https://github.com/*"], // Only on GitHub
}
export const getStyle = () => {
const style = document.createElement("style")
style.textContent = cssText
return style
}
export default function Sidebar() {
return <div className="my-sidebar">Hello GitHub!</div>
}
Choose if: You want the most productive React extension development experience.
Plasmo's CSUI deserves a deeper look because injecting React into arbitrary third-party pages is more complex than it appears. The standard content script approach — injecting a <div> and calling ReactDOM.render() into it — breaks on many sites due to CSS conflicts, style isolation issues, and event propagation problems. Plasmo's CSUI addresses these by mounting your React component in a Shadow DOM with the provided getStyle() function for CSS isolation. Your component's styles are completely scoped to the shadow root and don't conflict with the host page's CSS. For sidebar overlays, badge overlays, and feature injection into specific page elements, CSUI dramatically reduces the debugging time spent on CSS conflicts.
Plasmo's messaging system abstracts the tedious pattern of chrome.runtime.sendMessage / chrome.runtime.onMessage.addListener with typed message passing via sendToBackground and sendToContentScript. Type inference on message bodies eliminates a whole class of runtime errors where the receiver expects a different shape than the sender provides. For extensions with complex communication between popup, content scripts, and service worker, this type safety is meaningful.
Plasmo testing supports unit testing with Vitest and integration testing via Playwright with the @plasmohq/messaging/fixture package, which lets you test message handlers in isolation without spinning up a real browser extension environment. Content script testing is harder — JSDOM doesn't fully replicate the content script environment — but Plasmo's architecture (business logic in background, rendering in content scripts) makes unit testing the business logic straightforward.
WXT — Best Vite-Based
Price: Free | Creator: Aaron Klinker
Vite-based alternative to Plasmo. First-class TypeScript, auto-imports, entrypoints for all extension surfaces, multi-browser support (Chrome, Firefox, Safari), and better tree-shaking than Plasmo.
npx wxt@latest init my-extension
WXT's wxt.config.ts is the central configuration file where you define your extension's manifest properties, build options, and browser targets. The auto-import feature (borrowed from Nuxt/Vue patterns) means you don't need to import browser (the WebExtension API wrapper) or your commonly used utilities manually — WXT generates type-safe auto-imports based on your project structure.
Multi-browser support is WXT's strongest differentiator over Plasmo. Plasmo has Firefox support but treats Chrome as the primary target. WXT is browser-agnostic by design: browser.* API calls use the webextension-polyfill under the hood, manifest generation outputs Chrome MV3 or Firefox MV2/MV3 manifests based on the build target, and the TypeScript types cover the superset of Chrome and Firefox API surfaces with browser-specific features gated behind type guards. Building for Firefox and Edge alongside Chrome is a wxt build --browser firefox command.
Safari via Xcode is the one painful exception to multi-browser development. Safari extensions use Apple's Xcode-based build process, which requires macOS and Xcode installation. WXT generates the Xcode project from your web extension code using Apple's safari-web-extension-converter tool, but the final build and App Store submission still go through Xcode. If Safari extension support is a requirement, budget time for the macOS-only build step and the Apple review process (separate from Chrome Web Store).
E2E testing with @wxt-dev/testing launches a real Chromium instance with your extension installed, using WebdriverIO. This is the most reliable way to test extension behavior that depends on actual Chrome APIs: content script injection, storage operations, and tab manipulation. The setup is non-trivial but catches bugs that jsdom-based tests miss entirely — particularly the service worker lifecycle issues and cross-origin frame communication patterns that are common sources of production bugs.
CRXJS
Price: Free | Creator: Jack Steam
CRXJS is a Vite plugin rather than a full framework. Where Plasmo and WXT are opinionated about directory structure and provide extension-specific abstractions, CRXJS integrates into your existing Vite config and handles manifest processing, output chunking for Chrome extension requirements, and HMR for content scripts.
The core value proposition is HMR (Hot Module Replacement) in content scripts. Normally, modifying a content script requires reloading the extension and refreshing the target page. CRXJS patches content scripts to accept HMR updates without a full page refresh — a significant DX improvement for content-script-heavy extensions where the target page takes time to load or requires authentication to reach.
The limitations vs Plasmo/WXT: CRXJS provides no abstractions for messaging, storage, or multi-surface routing. You write standard Chrome extension code and CRXJS handles the build process. This is the right tradeoff for teams who want full control and already know Chrome extension APIs well. For teams new to extension development or wanting a more guided structure, Plasmo or WXT's higher-level abstractions reduce the surface area of things to learn.
Chrome Extension Architecture (MV3)
Understanding MV3's service worker model:
// background.ts — Service worker (not persistent!)
chrome.runtime.onInstalled.addListener(() => {
console.log('Extension installed');
});
// Service workers can be terminated! Store state in storage, not memory
chrome.storage.local.set({ initialized: true });
// Message passing between popup and content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_DATA') {
fetchData().then(sendResponse);
return true; // Keep channel open for async response
}
});
// popup.tsx — Communicates with background
const response = await chrome.runtime.sendMessage({ type: 'GET_DATA' });
Publishing to Chrome Web Store
The Chrome Web Store review process is 3-7 days for new extensions and typically hours for updates to established extensions. First-time developers often underestimate the review requirements.
MV3 requirements: All new extension submissions must use Manifest V3. The manifest must declare permissions minimally — only request permissions that the extension actually uses, and request them at the appropriate time (using chrome.permissions.request() for optional permissions rather than declaring them all in the manifest as required). Overly broad permissions are a common rejection reason, and the reviewer notes will cite which permission is unjustified.
Privacy policy requirements: Any extension that collects user data (including analytics events, user identifiers for subscription checks, or any data transmitted to your servers) must include a privacy policy URL in both the manifest metadata and your Chrome Web Store listing. The privacy policy must be publicly accessible — a GitHub README doesn't count. The privacy policy content must accurately describe what data you collect and why. Vague privacy policies that say "we may collect data" are increasingly rejected in 2026 as Chrome's review standards have tightened.
Single-purpose policy: Chrome's single-purpose policy requires that an extension "have a single purpose that is clear to users." This is enforced subjectively by reviewers, but the pattern that triggers it is extensions that bundle multiple unrelated features (a tab manager AND an ad blocker AND a note-taking tool). If your extension does multiple things, frame them as supporting a single core use case in your description and screenshots.
Review rejection common causes: Beyond permissions and privacy policy, the most common rejections are insufficient contrast ratio in screenshots (the Store has accessibility requirements for listing images), promotional language in the extension name or description ("Best," "Free," "#1"), and functional descriptions that don't match actual behavior. The reviewer will install and test your extension, so broken functionality or features visible in screenshots that aren't functional will cause rejection.
Firefox + Multi-Browser Support
Building for Firefox alongside Chrome doubles your distribution surface with roughly 3-5% of additional effort, assuming you're using WXT or a polyfill-based approach.
The webextension-polyfill (browser namespace) provides a Promise-based wrapper around Chrome's callback-based APIs that works on both Chrome and Firefox. WXT includes this by default. The key manifest differences: Firefox MV3 (available since Firefox 109) supports most Chrome MV3 APIs but with some gaps — declarativeNetRequest support is partial, and some Chrome-specific APIs like chrome.tabCapture don't exist in Firefox. For most productivity extensions, these gaps don't matter.
Firefox Add-ons store (addons.mozilla.org) has a separate review process from Chrome Web Store. Automatic review (instant approval) applies to extensions without remotely-hosted code that match known patterns. Manual review can take days to weeks. Firefox's reviewer criteria are similar to Chrome's: minimal permissions, accurate descriptions, functional code.
The polyfill strategy for cross-browser code: use browser.* for all WebExtension API calls (works on both Chrome and Firefox with the polyfill), guard Chrome-specific features with typeof chrome !== 'undefined' && chrome.specific !== undefined, and test on both browsers before submitting. WXT's multi-browser build (wxt build --browser firefox) generates a Firefox-compatible build with the correct manifest and polyfill bundled.
Chrome Extension Security
MV3's security model is stricter than MV2's, and for good reason. The attack surface for malicious extensions was significant in MV2 — an extension could dynamically load JavaScript from a remote server, execute arbitrary code, and modify any page.
Content Security Policy in MV3 prevents remote code execution by disallowing eval(), inline scripts, and remotely-hosted scripts in the extension's own pages (popup, options, newtab). Your extension code must be bundled at build time. The CSP is script-src 'self' by default and cannot be relaxed to allow remote scripts — this is a hard MV3 requirement, not an advisory.
Permissions minimization is both a security best practice and a Chrome Web Store requirement. Declare the minimum permission set required for your extension to function. For content scripts that only need to read the page DOM, you don't need tabs permission. For an extension that checks subscription status by calling your API from the background service worker, you need storage (to cache the status) but not cookies or history. Use optional permissions (declared in optional_permissions and requested at runtime via chrome.permissions.request()) for features that some users will use but not all — this reduces the permission prompt scope at install time and increases install conversion rates.
Cross-origin requests from content scripts are blocked by the host page's CORS policy. Content scripts run in the context of the web page, so a content script calling fetch('https://api.myextension.com') will be blocked if that API doesn't include a CORS header allowing the page's origin. The correct architecture: content scripts that need to call your API send a message to the background service worker, and the service worker makes the API call. Background service workers are not subject to CORS restrictions — they're treated as a different origin from the host page.
Extension Performance
Content script performance directly impacts user experience on the pages where your extension runs. A poorly optimized content script can slow down page load, cause layout jank, and drain battery — earning negative reviews.
Content script injection timing: The run_at parameter in your manifest determines when your content script executes. document_start runs before any page scripts and before the DOM is built — needed for ad blockers and CSP modifiers, but means you can't reference DOM elements that don't exist yet. document_idle (the default) runs after the page load event fires — safe to access all DOM elements, but might miss early-loading page features. document_end runs after the DOM is built but before subresources (images, fonts) load — the sweet spot for most UI injection use cases.
Avoiding layout thrash: Reading then writing DOM properties in a loop causes the browser to recalculate layout for each cycle. Batch reads before writes: read all layout properties you need, then write all DOM modifications together. For content scripts that modify multiple elements on a page, requestAnimationFrame batches visual changes to the next paint cycle, preventing mid-frame layout invalidations. For extensions that scan large pages (thousands of elements), IntersectionObserver lazily processes elements as they enter the viewport rather than processing the entire DOM upfront.
Memory leaks in long-running extensions: Content scripts live as long as the tab is open. Event listeners attached to DOM elements that are removed and recreated by single-page application navigation are a common source of memory leaks. The fix: clean up event listeners in a MutationObserver that watches for route changes, or use event delegation (attach listeners to a stable ancestor element rather than the specific elements that get replaced). For React-based content scripts via CSUI, React's cleanup in useEffect return functions handles this automatically as long as you implement cleanup correctly.
Building a Chrome Extension SaaS
The standard architecture for a Chrome extension SaaS product in 2026 is a web app for settings and billing, with the extension as the delivery mechanism for the core product value.
Web app for billing: Chrome Web Store payments were deprecated in 2020. You cannot process payments inside a Chrome extension directly. The standard flow: user installs the extension, is prompted to create an account on your web app, completes payment via Stripe on your web app, and the extension checks subscription status via your API. The web app handles the billing portal, plan changes, and cancellation.
Subscription status validation: The extension calls your API with the user's auth token to check subscription status. This call should be cached in chrome.storage.local with a TTL of 1-24 hours (longer TTL = fewer API calls but potentially stale access control). On every startup and periodically in the background, refresh the cached status. For the auth token itself, store it in chrome.storage.session (cleared when the browser closes) or chrome.storage.local (persistent) depending on your security requirements. Never store auth tokens in localStorage in the extension's popup page — that storage is isolated to the popup's origin and doesn't persist between popup open/close cycles in MV3.
User management across extension + web app: Users should log in once (on the web app or via an OAuth flow triggered by the extension) and have that session persist across both surfaces. The practical implementation: your web app generates a session token on login, the user copies it into the extension (UX-intensive but simple), or you implement an OAuth-like redirect flow where the extension opens your web app in a new tab, the user completes login, the web app sends a message to the extension via chrome.runtime.sendMessage with the session token. The latter requires the web app to know the extension ID to call sendMessage, which means embedding the extension ID in your web app. Alternatively, use chrome.identity.getAuthToken for Google Sign-In, which handles the OAuth flow natively within the extension.
Authentication setup for the web app that backs your extension follows the same patterns as any SaaS. See Authentication Setup for Next.js Boilerplates for the implementation details.
Monetizing Chrome Extensions
Chrome extensions can be monetized via:
- Stripe subscription — Link to web app for billing; extension checks subscription status via API
- License keys — Simple, no web backend required; validate against a key server
- Chrome Web Store payments — Deprecated; Google removed it in 2020
- Freemium — Free features + paid features locked behind account/subscription
The standard pattern in 2026: extension checks localStorage or calls your API to verify active subscription before unlocking premium features.
Methodology
Chrome extension boilerplate comparison based on direct evaluation of Plasmo 0.88+, WXT 0.19+, and CRXJS as of Q1 2026. MV3 architecture guidance based on Chrome Developers documentation and Chromium MV3 specification. Chrome Web Store review policies based on Google's Chrome Web Store program policies as of Q1 2026.
For open source SaaS foundations to pair with a Chrome extension backend, see Best Open Source SaaS Boilerplates 2026. For authentication setup in the web app that backs your extension, see Authentication Setup for Next.js Boilerplates. Browse all boilerplates in the StarterPick directory.
Check out this boilerplate
View Plasmoon StarterPick →