Building an Options Page with React
Build an MV3 extension options page in React: a typed settings hook backed by chrome.storage, live updates from other contexts, forms with validation, embedding in chrome://extensions, CSP-compatible builds, and testing.
Table of Contents
An options page starts as six checkboxes and grows into tabs, per-site rules, import/export and an account section. At that point plain DOM code becomes a tangle of event listeners and manual re-renders, and many teams move the page to React. The move is straightforward — an options page is an ordinary HTML page in the extension’s origin — but the details that make it good are extension-specific: settings live in chrome.storage, not component state; changes from the popup or another device must appear live; the page may be embedded in a small frame inside chrome://extensions; and the build must satisfy the extension’s Content Security Policy. This guide builds that page. It belongs to options page layouts.
Where React fits in an extension’s settings
The source of truth for settings is chrome.storage.sync (or local), shared by the service worker, content scripts, popup and options page. React should render that state, not own it: the options page reads storage on mount, writes to storage on change, and subscribes to storage.onChanged so updates from elsewhere re-render. A custom hook encapsulates all of this, so components look like they are using ordinary state. The page is declared in the manifest with options_ui (embedded in the extensions page, or opened in a tab with open_in_tab: true) or options_page (always a tab). Bundlers such as Vite produce CSP-safe output — no inline scripts, no eval — which MV3 requires.
Step-by-step: a React options page
1. Declare the page and the entry
1"options_ui": { "page": "options.html", "open_in_tab": true }
1<!-- options.html -->
2<!doctype html>
3<html lang="en">
4 <head><meta charset="utf-8"><title>Readable settings</title></head>
5 <body><div id="root"></div><script type="module" src="/src/options/main.tsx"></script></body>
6</html>
Execution context: the manifest and the page’s HTML. open_in_tab: true gives a large settings UI room; leave it false for a short page that fits in the embedded frame. The script tag points at the bundler entry; the build rewrites it to a hashed file with no inline code.
2. Define a typed settings schema with defaults
1// settings.ts
2export interface Settings {
3 enabled: boolean;
4 theme: "system" | "light" | "dark";
5 highlightColor: string;
6 blockedSites: string[];
7}
8export const DEFAULTS: Settings = { enabled: true, theme: "system", highlightColor: "#fde68a", blockedSites: [] };
Execution context: a module shared by all extension contexts. One schema and one defaults object keep the popup, worker and options page consistent. Storing each setting as its own key (rather than one big object) lets storage.sync merge concurrent edits from different devices per key and keeps per-item quota low.
3. Write the useSettings hook
1// useSettings.ts
2import { useEffect, useState, useCallback } from "react";
3import { DEFAULTS, type Settings } from "./settings";
4
5export function useSettings() {
6 const [settings, setSettings] = useState<Settings | null>(null);
7
8 useEffect(() => {
9 chrome.storage.sync.get(DEFAULTS).then((s) => setSettings(s as Settings));
10 const onChanged = (changes: Record<string, chrome.storage.StorageChange>, area: string) => {
11 if (area !== "sync") return;
12 setSettings((prev) => prev && { ...prev, ...Object.fromEntries(Object.entries(changes).map(([k, v]) => [k, v.newValue ?? DEFAULTS[k as keyof Settings]])) });
13 };
14 chrome.storage.onChanged.addListener(onChanged);
15 return () => chrome.storage.onChanged.removeListener(onChanged);
16 }, []);
17
18 const update = useCallback(<K extends keyof Settings>(key: K, value: Settings[K]) => {
19 setSettings((prev) => prev && { ...prev, [key]: value }); // optimistic
20 return chrome.storage.sync.set({ [key]: value });
21 }, []);
22
23 return { settings, update };
24}
Execution context: the options page. get(DEFAULTS) returns stored values with defaults filled in. The listener merges changed keys, falling back to the default when a key is removed. Updates are optimistic — the UI changes immediately and storage follows — and the subsequent onChanged event is idempotent. Removing the listener on unmount keeps hot reload in development clean. See syncing options form state with chrome.storage.
4. Build controls on the hook
1// OptionsApp.tsx
2export function OptionsApp() {
3 const { settings, update } = useSettings();
4 if (!settings) return <p aria-busy="true">{t("loading")}</p>;
5 return (
6 <main>
7 <h1>{t("settingsTitle")}</h1>
8 <label><input type="checkbox" checked={settings.enabled} onChange={(e) => update("enabled", e.target.checked)} /> {t("enable")}</label>
9 <label>{t("theme")}
10 <select value={settings.theme} onChange={(e) => update("theme", e.target.value as Settings["theme"])}>
11 <option value="system">{t("themeSystem")}</option><option value="light">{t("themeLight")}</option><option value="dark">{t("themeDark")}</option>
12 </select>
13 </label>
14 <BlockedSites value={settings.blockedSites} onChange={(v) => update("blockedSites", v)} />
15 </main>
16 );
17}
18const t = (k: string) => chrome.i18n.getMessage(k);
Execution context: the options page. Controls are standard controlled inputs, bound to the hook. chrome.i18n.getMessage works directly in components. Use native elements with labels for accessibility, as described in accessible form controls for extension settings.
5. Debounce text fields
1function useDebouncedSave<T>(value: T, save: (v: T) => void, ms = 400) {
2 useEffect(() => { const id = setTimeout(() => save(value), ms); return () => clearTimeout(id); }, [value]);
3}
Execution context: the options page. storage.sync has a write rate limit (MAX_WRITE_OPERATIONS_PER_MINUTE, 120). Saving every keystroke in a text field can hit it; keep local draft state in the component and save after a pause. See auto-save vs explicit save in options.
6. Build with a CSP-compatible bundler
1// vite.config.ts
2export default defineConfig({
3 plugins: [react()],
4 build: { rollupOptions: { input: { options: "options.html", popup: "popup.html" } }, sourcemap: true },
5});
Execution context: the build. Vite’s production output uses external module scripts only, compatible with the default MV3 CSP. Development servers that inject inline scripts or use eval don’t work in extension pages; use a framework-aware tool such as WXT or CRXJS for hot reload. See building extension pages with React, Vue or Svelte.
7. Fit the embedded frame when not opening in a tab
With open_in_tab: false, the page renders inside a frame in chrome://extensions whose width is fixed (roughly 400–600 px). Design for that width, avoid full-viewport layouts and modals, and let the frame grow with content height. The embedding options in the popup approach in embedding options in the popup reuses the same components at popup size.
8. Test the hook with a storage fake
1// useSettings.test.tsx
2import { renderHook, act, waitFor } from "@testing-library/react";
3beforeEach(() => { globalThis.chrome = makeChromeFake(); });
4test("reflects changes from other contexts", async () => {
5 const { result } = renderHook(() => useSettings());
6 await waitFor(() => expect(result.current.settings?.theme).toBe("system"));
7 await act(() => chrome.storage.sync.set({ theme: "dark" }));
8 expect(result.current.settings?.theme).toBe("dark");
9});
Execution context: a Vitest or Jest test with a fake chrome.storage that fires onChanged. Testing the hook covers the trickiest logic once. See testing popup components with Testing Library.
Common mistakes
- Settings in React state only. Other contexts never see them.
- No
onChangedsubscription. The page shows stale values after popup edits. - Saving every keystroke to sync. Hits the write limit.
- Dev builds with inline scripts. Blocked by the extension CSP.
- Full-viewport layouts in the embedded frame. Cramped and clipped.
Cross-browser variation
- Chrome / Edge:
options_uiembedded or in a tab;chrome.storagereturns promises. - Firefox:
options_uiis shown in the add-ons manager;browser.storagepromises; usewebextension-polyfillor abrowser ?? chromealias. - Safari: options pages are supported;
storage.syncdoes not sync across devices in all versions, so treat it like local.
Verification
- Change a setting in the popup with options open and confirm the form updates.
- Type in a text field and confirm storage writes happen after pauses, not per keystroke.
- Load the production build and confirm no CSP errors in the console.
- Open options embedded and in a tab and confirm the layout works in both.
FAQ
Should I use a state library such as Redux?
Usually not for settings — chrome.storage is already the store. Use one if the page has complex non-settings state.
Can the options page and popup share components?
Yes. Put shared controls in a package or folder imported by both entries.
How do I open the options page from elsewhere?
chrome.runtime.openOptionsPage() from any extension context.
Does React add noticeable load time to the options page?
Rarely. Options pages are opened occasionally and are not on the critical path the way the popup is. Still, split rarely used sections — account, import/export — into lazy-loaded chunks so the main form appears first.
Related
- Syncing options form state with chrome.storage — the framework-free version.
- Auto-save vs explicit save in options — choosing a save model.
- Reacting to option changes in every context — the other side of
onChanged. - Options page layouts — the parent topic.