Skip to main content
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 and wraps them for runtime mounting and hot module replacement (HMR) behavior. It 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 MAIN world (page JavaScript context) or isolated world (sandboxed extension context)
Predictable output layoutEmit normalized content_scripts/* artifacts

Template examples

content

content template screenshot Minimal content script setup with vanilla JS.
npx extension@latest create my-extension --template=content
Repository: extension-js/examples/content

content-react

content-react template screenshot Inject a React-powered UI into web pages through content scripts.
npx extension@latest create my-extension --template=content-react
Repository: extension-js/examples/content-react

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

Example content script declaration in manifest.json:
{
  "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. This is a function that sets up behavior and optionally returns a cleanup callback:
  • The module should export default a synchronous function.
  • That function should perform setup work.
  • It may return a synchronous cleanup callback.
  • Extension.js does not support classes as the default export.
This applies to:
  • files referenced by manifest.json > content_scripts[*].js
  • files you place under the project scripts/ folder and use as script entrypoints

Valid shapes

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

Invalid shapes

export default class App {}
export default {}

Guidance for async content scripts

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 function, you can easily 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.
  • Default export returns a Promise: the module still runs, but Extension.js does not treat that Promise as cleanup.
If your content script appears to compile but never mounts, check the default export first.

Runtime wrapper behavior

  • Extension.js wraps content script modules with mount/runtime helpers.
  • In development mode, Extension.js adds HMR accept/dispose behavior and remount flow.
  • CSS updates trigger remount events (__EXTENSIONJS_CSS_UPDATE__) in development.
  • Extension.js respects run_at timing from manifest values.

Multi-entry content scripts

You can declare multiple content script entries in a single manifest. Each entry compiles independently with its own match patterns, run timing, and world settings.

content-multi-one-entry

content-multi-one-entry template screenshot Multiple content scripts bundled under one content_scripts manifest entry.
npx extension@latest create my-extension --template=content-multi-one-entry
Repository: extension-js/examples/content-multi-one-entry

content-multi-three-entries

content-multi-three-entries template screenshot Three separate content_scripts manifest entries with independent match patterns.
npx extension@latest create my-extension --template=content-multi-three-entries
Repository: extension-js/examples/content-multi-three-entries

scripts/ folder behavior

The scripts/ folder is for script entrypoints that no HTML page entry declares. In practice, these entries follow the same default-export pattern 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
  • Extension.js treats adding or removing supported files under scripts/ as a structural change in watch mode
  • Extension.js may require a dev server restart when that entry set changes

Output path

Extension.js normalizes content script entries per manifest index:
content_scripts/
├── content-0.js          # production
├── content-0.abcd1234.js # development (hash-based cache busting)
├── content-0.css
└── ...
In development mode, content script JS filenames include a short hash suffix (for example, content-0.abcd1234.js). This forces the browser to load a fresh chrome-extension:// URL after each rebuild. Chrome aggressively caches extension resources, so the hash prevents stale code. Production builds use clean content-0.js names.

MAIN world notes

content-main-world

See MAIN world content scripts in action with a working example that injects UI directly into the page context:
npx extension@latest create my-extension --template=content-main-world
Repository: extension-js/examples/content-main-world
  • world: "MAIN" is Chromium-only. Firefox does not support the world field and ignores it. Your script still runs in the isolated world on Firefox.
  • Cross-browser MAIN world behavior: Use the chromium: manifest prefix to declare it only for Chromium targets. Then provide an isolated-world fallback for Firefox.
  • In development, MAIN-world scripts use an internal bridge mechanism to load code chunks and resolve public paths.
  • Extension APIs (chrome.runtime, chrome.storage, etc.) are not available in the MAIN world — you can only access page-context globals.
  • Treat MAIN world as an advanced path. Validate behavior on each target browser 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 you need page-context access, and account for extension API/runtime constraints.

Cross-browser MAIN world pattern

Use browser-specific prefixes to declare MAIN world only for Chromium and provide an isolated fallback for Firefox:
{
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["./scripts/isolated.ts"]
    },
    {
      "matches": ["<all_urls>"],
      "chromium:js": ["./scripts/main-world.ts"],
      "chromium:world": "MAIN"
    }
  ]
}
Firefox skips the chromium: prefixed fields entirely, so only Chromium targets get the MAIN-world script.

Matching and execution guidance

The browser still controls where a content script runs. Extension.js bundles the file, but the manifest entry still defines where and when the script runs.
  • 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 may require a 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 content script.
  • Default to isolated world and move to MAIN only when the page context is strictly required.

Next steps