Building Extension Pages with React, Vue or Svelte
Use React, Vue or Svelte for MV3 popups, options pages and side panels: CSP-safe builds, one entry per page, sharing components, state from chrome.storage, mounting in shadow roots, and bundle size.
Table of Contents
The popup grew from a button into a small application with tabs, forms and lists, and hand-written DOM code no longer scales. React, Vue and Svelte all work in extension pages, but the defaults of their web tooling assume a single-page app served from a web server: one entry, runtime template compilation in some setups, and state that lives as long as the tab. Extension pages are several small apps, loaded from the package under a strict content security policy, closed and reopened constantly, with state that must live in chrome.storage. This guide sets up a UI framework to fit. It belongs to build tooling and bundlers.
What changes for frameworks in an extension
Three constraints shape the setup. CSP: MV3 extension pages run with script-src 'self', so anything that compiles templates or code at runtime — Vue’s full build with in-DOM templates, libraries using new Function — fails; frameworks must be compiled ahead of time, which their standard Vite setups already do. Multiple entries: the popup, options page and side panel are separate HTML documents, each bootstrapping its own app; they share components through imports, not through one router. Ephemeral pages: the popup is destroyed every time it closes, so component state is lost; persistent state must come from chrome.storage and be rendered on open without a flash of empty UI. A fourth constraint applies only to content-script UI: the component must mount inside a shadow root with its styles there, not in the page’s <head>.
Step-by-step: a framework-based extension UI
1. Configure one entry per page
1// vite.config.js
2import { defineConfig } from "vite";
3import react from "@vitejs/plugin-react"; // or vue() / svelte()
4export default defineConfig({
5 plugins: [react()],
6 build: {
7 outDir: "dist",
8 rollupOptions: {
9 input: {
10 popup: "src/popup/index.html",
11 options: "src/options/index.html",
12 sidepanel: "src/sidepanel/index.html",
13 },
14 },
15 },
16});
Execution context: build configuration. Each HTML file is an entry; Vite bundles each app and extracts shared components and the framework runtime into common chunks. Build the service worker and content scripts separately with the formats they require, as in bundling with esbuild or Rollup, or let a framework like WXT handle it.
2. Make sure everything is compiled ahead of time
1// Vue: use the runtime-only build (the default with Vite + SFCs)
2import { createApp } from "vue"; // resolves to vue.runtime.esm-bundler.js
3// Avoid: template strings in components, e.g. { template: "<div>{{ x }}</div>" }
Execution context: extension pages. React (JSX), Svelte (compiler) and Vue single-file components are compiled at build time and are CSP-safe. Vue’s in-DOM templates and string template options need the runtime compiler, which uses new Function and is blocked by the extension CSP with “Refused to evaluate a string as JavaScript”. If you see that error, search for a runtime compiler or an eval-using dependency.
3. Bind components to chrome.storage
1// shared/useStorage.ts (React)
2import { useEffect, useState } from "react";
3
4export function useStorage<T>(area: "local" | "sync", key: string, fallback: T) {
5 const [value, setValue] = useState<T | undefined>(undefined);
6 useEffect(() => {
7 chrome.storage[area].get(key).then((r) => setValue((r[key] as T) ?? fallback));
8 const onChange = (c: Record<string, chrome.storage.StorageChange>, a: string) => {
9 if (a === area && key in c) setValue((c[key].newValue as T) ?? fallback);
10 };
11 chrome.storage.onChanged.addListener(onChange);
12 return () => chrome.storage.onChanged.removeListener(onChange);
13 }, [area, key]);
14 const update = (next: T) => chrome.storage[area].set({ [key]: next });
15 return [value, update] as const; // undefined while loading
16}
Execution context: any extension page. Storage is the source of truth; component state mirrors it and updates when any context changes it — the options page and popup stay in sync automatically. Returning undefined while loading lets components render a skeleton instead of a misleading default. Vue (ref + onMounted) and Svelte (a writable store) versions follow the same pattern.
4. Render quickly when the popup opens
1<!-- popup/index.html -->
2<body>
3 <div id="app"><div class="skeleton" aria-busy="true"></div></div>
4 <script type="module" src="./main.tsx"></script>
5</body>
Execution context: the popup’s HTML. A static skeleton in the HTML paints before the framework loads, so the popup never flashes empty. Keep the popup’s initial bundle small — lazy-load secondary tabs, as in code splitting and dynamic imports in MV3 — and read storage in parallel with mounting. See loading popup data without a flash of empty UI.
5. Mount content-script UI in a shadow root
1// content.tsx (bundled as IIFE)
2import { createRoot } from "react-dom/client";
3import css from "./overlay.css?inline";
4import { Overlay } from "./Overlay";
5
6const host = document.createElement("readable-overlay");
7const shadow = host.attachShadow({ mode: "closed" });
8shadow.innerHTML = `<style>${css}</style><div id="root"></div>`;
9document.documentElement.append(host);
10createRoot(shadow.getElementById("root")!).render(<Overlay />);
Execution context: a content script in the page’s isolated world. Importing CSS as a string and placing it in the shadow root keeps framework styles away from the page and page styles away from the component. CSS-in-JS libraries that inject into document.head need configuring to target the shadow root instead. A framework adds tens of kilobytes to a content script that may run on every page — for small overlays, consider Preact or plain DOM. See building a floating action button on web pages.
6. Keep bundles in check
1npx vite build && npx source-map-explorer dist/assets/*.js
Execution context: a terminal. Popups open dozens of times a day; every kilobyte of the initial chunk is parsed each time. Check which dependencies dominate, prefer lighter alternatives for date handling and icons, and keep the framework runtime in a shared chunk loaded once per page. See keeping the extension bundle small.
7. Test components without the browser
Write component tests with a DOM test library and a mocked chrome object, so most UI logic is verified in milliseconds; reserve end-to-end tests for wiring. See testing popup components with Testing Library.
Common mistakes
- Runtime template compilation. Blocked by CSP; compile ahead of time.
- One SPA with a router for every surface. Each surface is a separate page; share components instead.
- State only in components. The popup forgets everything when it closes.
- Framework styles in the page head from content scripts. Mount in a shadow root.
- Heavy frameworks in content scripts on every page. Use lighter options or load on demand.
Cross-browser variation
- Chrome / Edge: standard Vite builds work; CSP enforcement as described.
- Firefox: same CSP rules; React DevTools and Vue DevTools work in extension pages when the DevTools extensions are installed and allowed.
- Safari: works the same; test popup sizing, which Safari computes differently from content.
Verification
- Load each page and confirm no CSP errors in its console.
- Change a setting in options with the popup open and confirm the popup updates.
- Throttle the CPU in DevTools and confirm the popup shows the skeleton immediately and real data within a moment.
- Inspect a content-script overlay and confirm its styles live inside the shadow root.
FAQ
Which framework is best for extensions?
Any compiled framework works. Smaller runtimes (Svelte, Preact, Solid) help content scripts and popups most.
Can the popup and side panel share one app instance?
No. They are separate documents. Share state through storage and code through imports.
Do framework DevTools work in the popup?
Yes, if you open DevTools on the popup (right-click → Inspect) with the framework’s DevTools extension enabled for extension pages.
What about routing inside the options page?
A hash-based router works well inside a single extension page — options.html#/sync, options.html#/advanced — and makes deep links from the popup possible. Avoid history-based routers that assume a server; extension pages have no fallback route.
Related
- Building an options page with React — a full options page example.
- Sharing code between popup, options and side panel — structuring shared modules.
- Using web components in extension popups — the framework-free alternative.
- Build tooling and bundlers — the parent topic.