Sharing Code Between Popup, Options and Side Panel

Structure an MV3 extension so three surfaces share one data layer and one component set — module boundaries, per-surface entry points, and what must not be shared.

Published September 18, 2026 Updated September 18, 2026 9 min read
Table of Contents

By the second release an extension has a popup, an options page and often a side panel, and each has its own copy of the settings reader, its own storage key constants and its own slightly different idea of what a “site entry” looks like. They drift, and the drift shows up as a setting that saves in one surface and does not appear in another. The fix is a deliberate module boundary, not discipline. This guide is part of extension popup architecture.

What the three surfaces actually have in common

They are all extension pages on the same origin, with the same chrome.* access and the same CSP. What differs is lifetime, viewport and the interactions each can complete — which means the data layer can be fully shared, the components mostly, and the layout almost not at all.

What is shared and what is per-surfaceFour layers from shared domain modules through shared components to per-surface layout and entry points, showing how much of each is reusable.Domain + storage modulessettings, sites, sync state100% shared, no DOMComponentstoggle, site row, empty stateshared, layout-agnosticLayoutpopup / options / panel shellsper surfaceEntry pointspopup.js, options.js, panel.jsper surface, thin
Sharing stops at layout — a 360-pixel popup and a full options page do not want the same arrangement of the same components.

Step-by-step

1. Put every storage key behind one module

 1// src/data/settings.js
 2const KEY = "settings";
 3export const DEFAULTS = { theme: "auto", syncHour: 7, enabledOrigins: [] };
 4
 5export async function read() {
 6  const { [KEY]: raw } = await chrome.storage.sync.get(KEY);
 7  return { ...DEFAULTS, ...(raw ?? {}) };
 8}
 9
10export async function patch(partial) {
11  const next = { ...(await read()), ...partial };
12  await chrome.storage.sync.set({ [KEY]: next });
13  return next;
14}
15
16export function subscribe(fn) {
17  const handler = (changes, area) => {
18    if (area === "sync" && KEY in changes) fn({ ...DEFAULTS, ...changes[KEY].newValue });
19  };
20  chrome.storage.onChanged.addListener(handler);
21  return () => chrome.storage.onChanged.removeListener(handler);
22}

Execution context: any extension page, and the service worker. The module has no DOM dependency, which is what lets the worker import it too — and that is the property that stops the worker and the UI disagreeing about the default value of a setting.

The subscribe function returning an unsubscribe is what makes the module safe in the side panel, which lives long enough to accumulate listeners.

2. Write components that take data, not selectors

A component that queries the document for its own container cannot be reused. One that is handed an element and a value can.

 1// src/ui/toggle.js
 2export function toggle(root, { label, checked, onChange }) {
 3  root.innerHTML = "";
 4  const id = `t-${Math.random().toString(36).slice(2, 8)}`;
 5  const input = Object.assign(document.createElement("input"), { type: "checkbox", id, checked });
 6  const text = Object.assign(document.createElement("label"), { htmlFor: id, textContent: label });
 7  input.addEventListener("change", () => onChange(input.checked));
 8  root.append(input, text);
 9  return { set: (v) => { input.checked = v; } };
10}

Execution context: any extension page. Generating the id rather than accepting one means two instances on the same page cannot collide — the duplicate-id problem that fails an accessibility audit, checked in accessible form controls for extension settings.

3. Keep entry points thin

 1// src/entries/popup.js
 2import { read, patch, subscribe } from "../data/settings.js";
 3import { toggle } from "../ui/toggle.js";
 4
 5const settings = await read();
 6const t = toggle(document.querySelector("#enabled"), {
 7  label: "Enabled on this site",
 8  checked: settings.enabledOrigins.includes(origin),
 9  onChange: (v) => patch({ enabledOrigins: nextOrigins(settings, v) }),
10});
11
12subscribe((s) => t.set(s.enabledOrigins.includes(origin)));

Execution context: the popup document. The entry point does three things — read, mount, subscribe — and contains no business logic. The same three lines appear in options.js and panel.js with a different layout around them.

4. Share the module graph, not the bundle

Each surface gets its own HTML and its own entry, and the bundler emits a shared chunk. That keeps the popup’s payload small while the options page, which can afford more, pulls in what it needs.

 1// vite.config.js
 2export default {
 3  build: {
 4    rollupOptions: {
 5      input: {
 6        popup: "src/entries/popup.html",
 7        options: "src/entries/options.html",
 8        panel: "src/entries/panel.html",
 9        sw: "src/entries/service-worker.js",
10      },
11    },
12  },
13};

Execution context: the build, in Node. The service worker must be a separate input with no shared chunk that touches the DOM — a shared module importing document would throw on worker start. The configuration is expanded in bundling an MV3 extension with Vite.

5. Know what must not be shared

  • Lifetime assumptions. A popup component may safely skip cleanup; a side-panel component may not, because it can live for hours.
  • Focus management. The options page may take focus on load; the popup should not steal it, and the side panel must not steal it from the page.
  • Sizing. A component that sets a fixed pixel width works in a popup and breaks in a resizable panel.
  • Anything that opens a new surface. chrome.tabs.create from the popup closes the popup; from the options page it does not. The calling surface decides.
1// Surface capability, passed in rather than detected inside the component.
2export function siteRow(root, { site, canOpenInTab, onOpen }) {
3  // …render…
4  if (canOpenInTab) root.append(openButton);
5}

Execution context: any extension page. Passing the capability keeps the component testable and stops it growing a location.pathname.includes("popup") check, which is the shape this always takes when detection is done inside.

One data layer, three shellsShared settings and site modules feed shared components, which are arranged by three thin per-surface entry points into a popup, an options page and a side panel.data/settings.jsread, patch, subscribedata/sites.jsenabled originsui/*.jstoggle, row, emptyimported by every entry pointpopup.js360 px, one actionoptions.jsfull page, all settingspanel.jsresizable, long-lived
A change to a storage shape is one edit; a change to a layout touches exactly one entry point.

Where shared code usually goes wrong

Three failure patterns account for most of the pain, and all three are architectural rather than accidental.

The god module. A single shared.js that grows to hold storage access, DOM helpers, formatting and the message protocol. It gets imported everywhere, including into the service worker, and the first time someone adds a document.createElement to it the worker throws on cold start with an error that names a line nobody associates with the worker. Split by dependency, not by convenience: modules that touch the DOM live in one directory, modules that do not live in another, and the worker may only import from the second.

The component that reads storage. It is tempting to have the site-row component fetch its own data — fewer parameters, less plumbing. It also means the component cannot be rendered twice without two reads, cannot be tested without a chrome mock, and re-reads on every render. Components take values; entry points do I/O.

The runtime surface check. Once one component behaves differently in the popup, a check appears — location.pathname.includes("popup") — and within two releases there are six of them in four files. Pass capability flags down from the entry point instead, as in step 5.

A quick structural test catches all three: can the data directory be imported by a Node test with no chrome global and no DOM? If not, something is in the wrong place.

1// test/data.test.js — no jsdom, no chrome mock beyond storage
2globalThis.chrome = { storage: { sync: fakeArea(), onChanged: fakeEvent() } };
3const { read, DEFAULTS } = await import("../src/data/settings.js");
4assert.deepEqual(await read(), DEFAULTS);

Execution context: Node, under your test runner. The fake needs only the surfaces the module actually touches, which is a good sign in itself — a data module that requires a large mock is doing too much.

Where each kind of code belongsFive categories of extension code mapped to whether they may import the DOM, whether the service worker may import them, and where they are tested.CodeMay touch the DOMWorker may importTested withdata/ modulesNoYesNode + storage fakeui/ componentsYesNeverjsdom or a real browserentries/ scriptsYesNeverEnd-to-endworker/ handlersNoYesNode + chrome fakemessages.jsNoYesType-level only
The middle column is the rule the directory structure should make obvious.

Cross-browser variation

  • Chrome / Edge: all three surfaces exist. The side panel is Chrome 114+, so the panel entry point should be built and shipped but only reachable where chrome.sidePanel exists.
  • Firefox: the equivalent of the side panel is sidebarAction, which loads an ordinary extension page — the same panel.html works with a different manifest key, as covered in generating a manifest per browser target.
  • Safari: popup and options only. The panel entry is simply not built into the Safari target, which is cleaner than shipping an unreachable page.
  • All three: every surface shares one origin and one storage. Two surfaces open at once will both receive storage.onChanged, which is what makes subscribe sufficient for keeping them in step.

Verification

  1. Confirm the surfaces agree. Open the options page and the popup, change a setting in one, and watch the other update without a reload.
  2. Check the worker does not pull in DOM code:
1grep -n "document\.\|window\." dist/service-worker.js | head
2# (no output)

Execution context: your shell, against the built bundle. A hit here means a shared module reached the worker’s chunk and will throw on the next cold start.

  1. Confirm the shared chunk exists and the popup bundle is small — check the emitted file sizes in your build output.
  2. Open the side panel, leave it for ten minutes with the options page opening and closing, and confirm listener count is stable in DevTools Memory.

FAQ

Should the service worker import the same modules?

The data modules, yes — that is the point. The UI modules, no. Keep the two directories separate so the boundary is visible in the import path rather than in a convention.

Is one HTML file with query parameters simpler than three?

It is fewer files and worse in every other way: the manifest needs distinct URLs anyway, the bundle is the union of all three surfaces, and the layout branches at runtime. Three entries with a shared chunk is both smaller and clearer.

How do I test a shared component?

Mount it into a detached element in a test and assert on the resulting DOM. Because components take data and callbacks rather than reading storage, no chrome.* mock is needed — see testing message handlers in isolation.

Other MV3 Architecture & Extension Lifecycle Resources