Back to blog
Guides7 min read

Chrome extension project structure for MV3

How Manifest V3's separate runtimes decide an extension's folder layout, what belongs in the service worker, and how to share UI across every surface.

An extension is several programs that share a manifest

The instinct carried over from web apps — one root, one bundle, one runtime — is the thing to unlearn first. A Manifest V3 extension is a set of separate programs with different lifetimes and different privileges: a background service worker that wakes for events, content scripts running inside other people's pages, and however many extension pages your product needs. They share a manifest and a storage area. They do not share a runtime.

That is why the folder structure is worth deciding deliberately. Group source by runtime first and by feature second, because the runtime boundary is the one the browser enforces.

src/
  background/          # service worker: events, coordination, storage
    index.ts
    messages.ts
    alarms.ts
  content/             # injected into matching pages
    index.ts
  views/               # extension-owned pages, one entry each
    popup/
    options/
    sidepanel/
    devtools/
  shared/
    types/             # message contracts, storage shapes
    browser/           # the API adapter (see below)
    ui/                # components used by the views
    domain/            # pure logic, no browser dependency
public/                # icons and web-accessible assets
manifest.json

The free Manifest V3 generator emits this shape with the manifest already consistent with it, and the Nodlume workspace plans extension projects on the same canvas as web apps — the Extension target keeps the web component catalogue, because a popup is still the DOM.

What Manifest V3 changed, and why the structure follows

MV3 replaced the persistent background page with an event-driven service worker. background.scripts became background.service_worker, and the worker is allowed to stop when idle. Extensions also may only run JavaScript packaged with the extension, not remotely hosted code.

Both changes push directly into the file tree. Durable state cannot live in a module-scope variable in the worker, because the worker is restartable — it belongs in chrome.storage. Long-running UI cannot live in the background at all, because there is no persistent page to host it. And since every executed line ships inside the package, a bundler is not optional: the source can be TypeScript with imports and React components, but the built output must match what the manifest declares, path for path.

The useful heuristic when placing a new file: if it touches the DOM of a website, it is a content script; if it renders your own UI, it is a view; if it reacts to a browser event, it is background. Anything that fits none of those is shared code, and shared code should not know which runtime imported it.

The manifest is the entry-point map

manifest.json sits at the root of the built extension and lists every entry point the browser will load. Treat it as configuration that points at build output, not as application code.

{
  "manifest_version": 3,
  "name": "Example",
  "version": "1.0.0",
  "background": { "service_worker": "background.js", "type": "module" },
  "action": { "default_popup": "views/popup/index.html" },
  "options_page": "views/options/index.html",
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["content.js"]
    }
  ],
  "permissions": ["storage"],
  "host_permissions": ["https://api.example.com/*"],
  "web_accessible_resources": [
    { "resources": ["icons/*.png"], "matches": ["https://example.com/*"] }
  ]
}

Keep it explicit and boring. Runtime-discovered entry points are harder to review, harder to debug, and read badly in Chrome Web Store review, where every permission is a question you have already answered or have not. The permission half of that conversation is its own subject — MV3 permissions covers activeTab, host patterns, and optional grants in detail, and the manifest generator post covers writing the file itself.

Surfaces: popup, options, side panel, new tab, DevTools

Each surface is its own HTML entry with its own script, and each has a shape of interaction it suits:

  • Popup — quick actions, dismissed on blur. Save this, run this, show status, open settings. It is not a dashboard; it disappears the moment the user clicks the page.
  • Options page — durable configuration. Account settings, feature toggles, anything a user sets once and expects to persist.
  • Side panel — companion workflows that stay visible while browsing. Research, notes, translation, assistance.
  • New tab override — only when the whole product is that surface. It is the most intrusive thing an extension can claim.
  • DevTools panel — developer tooling, and a separate audience from everything above.

For a React extension, treat each of these as a small app with its own root. What they share is components and domain logic, imported from shared/, not a single bundle loaded everywhere.

One routing note that catches people: extension pages are loaded from chrome-extension:// URLs, and a history-based router will fight that. A hash router works without configuration across every surface, which is why generated extension projects use one.

The service worker is a coordinator, not a hidden page

The background worker registers listeners, handles messages, schedules alarms, and reads and writes storage. It is restartable at any time, and designing around that is most of what makes an extension reliable.

Three rules follow from restartability. Register listeners at the top level of the module, not inside an async callback — a worker woken by an event must have its handler attached before that event is dispatched, and a listener registered after an await may not exist yet. Make handlers idempotent, because a retried event is normal. And keep durable state in chrome.storage, reloading it when a handler needs it.

window.localStorage is not available in the service worker at all — there is no window. That single fact invalidates a surprising amount of extension code copied from web apps.

// background/index.ts — listeners at top level, state in storage
import { browser } from "@/shared/browser"
import { handleMessage } from "./messages"

browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  handleMessage(message).then(sendResponse)
  return true // keep the channel open for the async response
})

The return true is load-bearing: without it the message channel closes before an async handler resolves, and the caller receives undefined rather than an error. It is the most common silent bug in MV3 message passing.

Content scripts and match patterns

A content script runs inside a web page's context so the extension can read or modify that page. Match patterns decide where — and they are the part of the manifest most worth being conservative about. Broad patterns like <all_urls> touch every host the user visits, and they lengthen store review accordingly.

Design content scripts with restraint. Match only the URL shapes the feature needs, and keep the script small when it runs on many pages. Hold no sensitive state in the page environment, and delegate anything privileged to the background worker by message.

Match patterns are also the one manifest field worth validating rather than repairing. A malformed pattern does not degrade gracefully or get ignored — the browser rejects the entire extension at load. Tooling that silently "fixes" a pattern is guessing at intent on the one field where a wrong guess changes which sites your code runs on; the generator flags an invalid pattern and refuses to emit it instead.

Sharing code without shipping React four times

The goal is to share source modules while the bundler emits a separate entry per runtime. Declare explicit entries for background, content, and each view — never one bundle that every surface loads.

The layering that keeps this honest:

  • shared/types — message contracts and storage shapes. Imported by everything.
  • shared/browser — the API adapter. Imported by everything that talks to the browser.
  • shared/domain — pure logic, no browser dependency, trivially testable.
  • shared/ui — React components. Imported by views only.

The last boundary is the one that pays. A React component imported into the service worker pulls React into a bundle with no DOM to render into. Keeping shared/ui off-limits to background and content code is a convention worth enforcing in review, or with a lint rule.

Message contracts are worth modelling as a discriminated union rather than loose strings, which turns a whole class of cross-runtime mistake into a type error:

// shared/types/messages.ts
export type Message =
  | { type: "GET_SETTINGS" }
  | { type: "SAVE_SETTINGS"; settings: Settings }
  | { type: "EXTRACT_PAGE"; tabId: number }

export type Response<M extends Message> = M extends { type: "GET_SETTINGS" }
  ? Settings
  : void

Chrome, Edge, and Firefox from one source

Two differences matter structurally, and neither is a detail you can paper over late.

The first is the namespace. Chrome exposes callback-style chrome.*; Firefox exposes promise-based browser.*. Mozilla's webextension-polyfill provides the promise-based browser.* API on Chromium, so the fix is one adapter module every other file imports:

// shared/browser/index.ts
import polyfill from "webextension-polyfill"

export const browser = polyfill

Import that everywhere and never write chrome. in feature code. The exception is APIs Chromium has and Firefox does not — sidePanel is the common one, since Firefox uses sidebar_action instead — which need an explicit guarded branch rather than a silent failure.

The second difference is the background key itself, and it is the one that surprises people: Firefox does not support background.service_worker at all. It implements event pages via background.scripts. The cross-browser manifest declares both, pointing at the same built file:

{
  "background": {
    "service_worker": "background.js",
    "scripts": ["background.js"]
  }
}

Chrome reads service_worker and ignores scripts; Firefox does the reverse; Safari defaults to scripts. One build output, two declarations. This works only because the worker was written to be restartable in the first place — an event page and a service worker have compatible lifecycles, and code that assumed persistence breaks in both.

Frequently asked questions

What files does a Manifest V3 extension actually require?

Only manifest.json with manifest_version: 3, a name, and a version. Everything else — background worker, content scripts, popup, options page — is optional, and an extension that declares none of them installs and does nothing. In practice the smallest useful extension is a manifest plus one entry point, and the structure above only earns its keep once there are several.

Can a background service worker keep state between events?

Not in memory. The worker stops when idle and restarts on the next event, so module-scope variables are lost without warning — typically working in development, where the worker stays warm, and failing for users. Keep durable state in chrome.storage and read it at the start of each handler.

How do I share React components between the popup and the options page?

Put them in a shared UI module and import them from both entries, then configure the bundler with one entry per surface so each page loads only what it uses. Keep that module off-limits to background and content scripts, which have no DOM to render into.

What happens if a content script match pattern is invalid?

The browser rejects the whole extension at load — not just the offending script. This is why patterns are worth validating before emitting a manifest, and why a tool that silently rewrites a malformed pattern is doing something worse than failing: it is quietly changing which sites your code runs on.

Does the same folder structure work for Firefox?

The structure does; the manifest needs two adjustments. Declare background.scripts alongside service_worker, since Firefox does not support service workers, and branch explicitly on the Chromium-only APIs — sidePanel in particular, which Firefox replaces with sidebar_action. With webextension-polyfill behind a single adapter module, the rest of the source is genuinely shared.

Target-specific scaffolding

Continue this learning path

Permissions, manifests, capabilities, agent projects and MCP servers for browser extensions, desktop, mobile, and backend agents.

Explore platform generators

Design your application

Design Web, Desktop, Mobile, Terminal, or Extension projects with a target-aware canvas and exporter.

Open projects

We'd like to use Google cookies to understand how Nodlume is used and to measure our advertising. Nothing loads until you choose, and declining does not affect anything in the app.