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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

React renders storage; storage is the truthThe options page's useSettings hook reads chrome.storage on mount, writes changes back to storage, and subscribes to storage.onChanged so changes made in the popup, the service worker or on another synced device re-render the form.<OptionsApp>componentsuseSettings()hookchrome.storage.syncsource of truthstorage.onChangedPopupwrites a settingService workermigrationsOther devicesync
Components call a hook; the hook talks to chrome.storage.

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.

A change in the popup appears in open optionsThe options page is open with theme set to system; the user changes theme to dark in the popup; storage.sync fires onChanged in all contexts; the options page hook updates state and React re-renders the theme select.Popupchrome.storageOptions page (React)set({theme:'dark'})onChanged {theme}setState → re-r…
No polling, no reload — onChanged keeps every open page current.

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.

Save strategies for a React options pageSaving on every change, debounced saving for text fields, and explicit save with a draft compared on immediacy, quota use and when each fits.StrategyImmediate effectQuota pressureBest forSave on changeYesLow for togglesCheckboxes, selectsDebounced saveAfter pauseLowText inputsDraft + Save buttonOn SaveLowestRelated fields, validation
Toggles save instantly; text fields debounce; multi-field forms may want an explicit Save.

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 onChanged subscription. 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_ui embedded or in a tab; chrome.storage returns promises.
  • Firefox: options_ui is shown in the add-ons manager; browser.storage promises; use webextension-polyfill or a browser ?? chrome alias.
  • Safari: options pages are supported; storage.sync does not sync across devices in all versions, so treat it like local.

Verification

  1. Change a setting in the popup with options open and confirm the form updates.
  2. Type in a text field and confirm storage writes happen after pauses, not per keystroke.
  3. Load the production build and confirm no CSP errors in the console.
  4. 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.

Other UI/UX Patterns & Interactive Components Resources