Content scripts

Build page-integrated extension features with content scripts while keeping a reliable dev loop for JS and CSS updates.

Extension.js compiles content script entries from manifest.json, wraps them for runtime mounting/HMR behavior, and emits predictable content_scripts/* outputs.

Content script capabilities

CapabilityWhat it gives you
Manifest-driven entriesCompile JS/CSS content script lists directly from manifest
Dev remount flowUpdate scripts/styles quickly through wrapper-driven behavior
MAIN vs isolated world supportUse advanced page-context mode when required
Predictable output layoutEmit normalized content_scripts/* artifacts

Supported manifest fields

Manifest fieldFile type expected
content_scripts.js.js, .jsx, .ts, .tsx, .mjs
content_scripts.css.css, .scss, .sass, .less

Sample content script declaration

Below is an example of how to declare content scripts within the manifest.json file:

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["./scripts/content-script.ts"],
      "css": ["./styles/content-style.css"]
    }
  ]
}

Authoring contract

For every content-script-like entry, Extension.js expects a mount-style default export:

  • The module should export default a synchronous function.
  • That function should perform setup work.
  • It may return a synchronous cleanup callback.
  • Classes are not supported as the default export.

This applies to:

  • files referenced by manifest.json > content_scripts[*].js
  • files placed under the project scripts/ folder when they are used as script entrypoints

Valid shapes

export default function main() {
  return () => {}
}
const main = () => {}
export default main

Invalid shapes

export default class App {}
export default {}

Async guidance

Keep the default export synchronous even when the feature does async work internally. Start async work inside the function and return a synchronous cleanup.

Why this matters: Extension.js remounts content scripts during development. Without a cleanup contract, it is easy to duplicate UI, event listeners, observers, and timers.

What happens on contract violations

  • No default export: Extension.js warns during development and skips mounting.
  • Default export is not a function: Extension.js warns during development and skips mounting.
  • Async default export: the module still runs, but the returned Promise is not treated as cleanup.

If your content script appears to compile but never mounts, check the default export first.

Runtime wrapper behavior

  • Content script modules are wrapped with mount/runtime helpers.
  • Dev mode adds HMR accept/dispose behavior and remount flow.
  • CSS updates trigger remount events (__EXTENSIONJS_CSS_UPDATE__) in development.
  • run_at timing is respected from manifest values.

scripts/ folder behavior

The scripts/ folder is for script entrypoints that are not declared by an HTML page entry. In practice, these entries follow the same mount-style runtime expectations as content scripts.

That means scripts/ is not just a generic folder for loose JavaScript files:

  • script entry files should still have a default export function when they are meant to mount behavior
  • adding or removing supported files under scripts/ is treated as a structural change in watch mode
  • Extension.js may require a dev-server restart when that entry set changes

Output path

Content script entries are normalized per manifest index:

content_scripts/
├── content-0.js
├── content-0.css
└── ...

Main world notes

  • world: "MAIN" has dedicated bridge/runtime behavior and different extension API limitations.
  • MAIN-world scripts use bridge-based chunk loading/public-path handling in development.
  • Treat MAIN world as an advanced path and validate behavior on target browsers early.

Isolated vs MAIN quick example

{
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["./scripts/isolated.ts"]
    },
    {
      "matches": ["<all_urls>"],
      "js": ["./scripts/main-world.ts"],
      "world": "MAIN"
    }
  ]
}

Use isolated world by default. Use MAIN only when page-context access is required and account for extension API/runtime constraints.

Matching and execution guidance

The browser still controls where a content script runs. Extension.js bundles the file, but the matching contract still comes from the manifest entry.

  • Keep matches as narrow as the feature allows.
  • Add exclude_matches, all_frames, or match_about_blank only when the feature actually requires those behaviors.
  • Treat run_at and world as part of the feature contract, not just implementation detail.
  • Re-test permission and host-permission scope when changing where a content script runs.

Development behavior

  • Editing content script code usually updates through wrapper-driven HMR/remount flow.
  • CSS-only entries receive dev helper behavior so style updates can propagate.
  • If content script entrypoint lists change in manifest, Extension.js can require dev server restart.

Best practices

  • Keep content script entry files small and delegate logic to shared modules.
  • Scope selectors/styles carefully to avoid host-page collisions.
  • Prefer explicit run_at and world values when behavior depends on timing/context.
  • Treat manifest content-script list changes as structural development changes.
  • Pass page-derived data through validated messaging instead of performing privileged work directly in the page boundary.
  • Default to isolated world and move to MAIN only when the page context is strictly required.

Next steps