> ## Documentation Index
> Fetch the complete documentation index at: https://extension.js.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Building Safari extensions with Extension.js

> Develop and build your web extension as a Safari app on macOS with Extension.js. Covers requirements, the dev loop, logs, reload, and known limitations.

Develop a web extension against Safari on macOS, with no separate Xcode project
to maintain by hand.

<Note>
  Safari is supported on **macOS only** and requires the full Xcode app. `dev`
  and `build` both work. `preview` and `start` do not, for the reason described
  in [Command support](#command-support).
</Note>

`extension dev --browser safari` bundles your code, converts it with Apple's `safari-web-extension-converter`, builds and signs an app with `xcodebuild`, and opens it. You enable the extension once in Safari Settings. After that, `extension logs` streams background and content rows, the control bridge attaches, and every save reloads the extension in Safari. That dev loop ships from 4.1.20.

<Note>
  The behavior on this page was measured on macOS 26.5.2, Safari 26.5.2, and
  Xcode 26.6. Safari 27 exists and was not re-measured.
</Note>

<Frame>
  <iframe className="w-full aspect-video rounded-xl" src="https://www.youtube-nocookie.com/embed/Z9KqTxWxTYQ?rel=0" title="Extension.js: extension build --browser=safari" loading="lazy" allow="encrypted-media; picture-in-picture; fullscreen" allowFullScreen />
</Frame>

## What you need

Safari is **macOS only** and needs the **full Xcode app**, not just the Command Line Tools. The converter (`safari-web-extension-converter`) and `xcodebuild` ship inside `Xcode.app`.

```bash theme={null}
# Install Xcode from the Mac App Store, then point the toolchain at it:
sudo xcode-select --switch /Applications/Xcode.app
xcodebuild -runFirstLaunch
```

A paid Apple Developer account is **not** required. Without `--development-team` the app is signed ad-hoc. Safari accepts an ad-hoc signed app and lists the extension like any other, with the Develop menu setting **Allow unsigned extensions** turned off. That setting belongs to a different path, described in [Temporary extensions](#temporary-extensions-no-xcode).

If Xcode is missing, `extension dev` and `extension build` for Safari targets fail fast, before bundling, with guidance instead of a late, confusing error.

## What it produces

`extension build --browser=safari` creates, next to your project:

| Path | What it is |
| - | - |
| `dist/safari` | The bundled web extension (manifest, scripts, assets). |
| `dist/safari-xcode` | The generated Xcode project (app + Safari extension targets). |
| `…/Release/<App>.app` | The compiled app that hosts your extension, signed with your team id when `--development-team` is set and ad-hoc otherwise. |

The pipeline runs end to end: **bundle → convert → `xcodebuild`**, plus **open
the app → guided enable** in `dev` (or with `build --open`). A plain `build`
stops after packaging and prints the `open` command instead.

```bash theme={null}
npx extension build --browser=safari
```

The app name and bundle identifier are derived from your manifest `name` (for example, `React Sidebar Example` → bundle id `dev.extensionjs.React-Sidebar-Example`). The project targets macOS by default.

<Warning>
  The generated `dev.extensionjs.*` bundle id is a development placeholder. If
  you plan to distribute your app, set a bundle id you own **from the first
  build**. Changing it later makes Safari treat the extension as a brand-new
  identity (users lose their enable state and data).
</Warning>

## App identity and packaging options

Both `dev` and `build` accept identity overrides (Safari targets only):

| Flag | What it does |
| - | - |
| `--bundle-id` | User-owned reverse-DNS bundle identifier (e.g. `com.example.my-extension`). |
| `--app-name` | Override the app name (defaults to the manifest `name`). |
| `--development-team` | Apple Developer team id to sign with (e.g. `A1B2C3D4E5`). Without it the build is ad-hoc signed. |
| `--macos-only` | Generate a macOS-only Xcode project (default `true`). Pass `--macos-only false` for a universal macOS + iOS project. |
| `--force-regenerate` | Regenerate the Xcode project even when it is up to date. |
| `--safari-binary` | `dev` only: the Safari binary to open after packaging. |
| `--open` | `build` only: open the built app after packaging (off by default). |

Without `--bundle-id`, Extension.js derives `dev.extensionjs.<name>`, where `<name>` is your sanitized app name (non-alphanumeric runs become hyphens). A user-provided bundle id must be reverse-DNS shaped: at least two dot-separated segments of letters, digits, and hyphens, each starting with a letter. Invalid values are rejected before packaging.

The same options can live in `extension.config.js` (CLI flags win):

```js theme={null}
export default {
  browser: {
    safari: {
      appName: "My Extension",
      bundleId: "com.example.my-extension",
      developmentTeam: "A1B2C3D4E5",
    },
  },
};
```

Changing the bundle id, app name, or manifest regenerates the Xcode project on
the next run (see the regeneration warning below).

## Building the web-extension bundle on other platforms

`extension build --browser=safari` works on Linux and Windows too: it produces
the complete `dist/safari` web-extension bundle and **skips the Xcode packaging
step** with a warning. This lets CI build the payload anywhere and a Mac (or a
macOS runner) do the convert + `xcodebuild` part later. `dev --browser=safari`
still requires macOS with Xcode, because a Safari dev loop without packaging has
nothing to run.

## Enabling the extension in Safari

Extension.js prints the steps that match your build when the app opens (`dev`,
or `build --open`), and confirms when macOS has registered the extension.

The one-time gesture is the same for signed and ad-hoc builds:

1. Safari ▸ Settings ▸ Extensions ▸ turn on your extension.

The toggle survives Safari restarts, so this is genuinely one time per machine, not once per launch.

Turning the extension **on is not the same as giving it access to pages**.
Safari asks for website access separately, and until you grant it a content
script does not run at all: the extension is listed, enabled, and does nothing.
This holds even when your manifest declares `<all_urls>` in both
`content_scripts.matches` and `host_permissions`, which is the part that
surprises people coming from Chrome, where installing grants it.

In the same panel, under **Permissions**, use:

* **Always Allow on Every Website…** for an extension that you iterate on. It
  persists, so you grant it once.
* **Edit Websites…** to allow only the hosts that you are testing against.

If your extension loads but nothing happens on the page, this is almost always
why. Check the permission before you go looking at your code.

### Signed builds with an Apple Developer team

Pass `--development-team` with your Apple Developer team id and the app is
signed with your own certificate:

```bash theme={null}
extension dev --browser safari --development-team A1B2C3D4E5
```

To find your team id, run `xcrun security find-identity -v -p codesigning`. The
ten-character code in the certificate name is your team id, and it is also on
the Membership page of your Apple Developer account.

Team signing is what you need for distribution. For day-to-day development it
changes nothing about how Safari lists or runs the extension.

### Ad-hoc builds (no Apple Developer account)

Without `--development-team` the app is ad-hoc signed. Safari lists it and runs
it, and the enable gesture above is the whole setup. This was measured with
Safari ▸ Develop ▸ **Allow unsigned extensions** turned **off**.

### Temporary extensions (no Xcode)

Safari has a second path that Extension.js does not drive: Safari ▸ Settings ▸
Developer ▸ **Add Temporary Extension…**. It loads an unpacked folder such as
`dist/safari`, with no Xcode step at all. It needs **Allow unsigned extensions**,
and the extension disappears when Safari quits, or after 24 hours. Use it for a
quick look at a bundle. Use `extension dev` for a dev loop that survives a
restart.

## The dev loop

`extension dev --browser=safari` runs a watch loop:

```bash theme={null}
npx extension dev --browser=safari
```

* **First compile**: full package: convert, build, open the app, and print the enable steps.
* **On every save**: an incremental `xcodebuild` resync updates app resources from the rebuilt `dist/safari`. Safari reloads the extension.

Once the extension is enabled, the loop needs no further clicking. Measured on
Safari 26.5.2, a save reaches a reloaded extension in roughly 4 seconds.

A plain `extension dev --browser safari` opens the container app that the converter built, not Safari itself. Safari is where you enable and use the extension, so open it yourself, or pass `--safari-binary` and the session opens Safari beside the app. Once the extension is enabled and the session runs with `--allow-control`, `extension navigate <url>` points Safari's active tab at a page through the extension, a static `tabs` call rather than an eval, so a content script's page can be opened without touching the browser by hand.

Each save prints what it did. A resync ends with `Rebuilt <App>.`, and a content script change follows it with `Reloading content_script (content.ts)…`. When the extension is not connected at that moment, the line reads `Queued content_script (content.ts) for the extension to apply when it reconnects.` instead. A background or manifest edit prints only `Rebuilt`, because Safari restarts the extension itself after the resync, and there is nothing left to dispatch.

After a conversion, the converter's own warning list is printed, one line per manifest key that it does not know. A key that the build keeps on purpose is annotated under `Extension.js kept one of these keys on purpose:`. Today that is `world`, with the reason `Safari has honored the MAIN world since Safari 18, so the key stays`.

In dev, the bridge parks early errors. An error thrown before the extension's socket to the dev server opens is stored in the extension's `chrome.storage.local` under `__extjsPendingErrors` and replayed once the socket connects, so `extension logs` still shows it.

### What one save costs

Resyncs run in the background, so the bundler loop is never blocked. A burst of saves collapses to a single follow-up resync against the newest output, so five quick saves cost one rebuild, not five. If the first full package fails, the next compile retries the full flow instead of resyncing a project that was never built.

### When the Xcode project regenerates

The Xcode project is generated once and reused for resyncs. Staleness is decided
by a fingerprint file, `dist/safari-xcode/.manifest-fingerprint`. The v4
fingerprint, written from 4.1.20, stores the identity inputs (app name, bundle id, and the macOS-only
setting), the top-level entries of `dist/safari`, and the manifest `icons` set.
Of the manifest itself it stores only the surface the converter judges. That
surface is the top-level key names, the `permissions` and `optional_permissions`
values, and the key names inside each `content_scripts` entry and `options_ui`. The other
manifest bytes stay out, so the content-hashed script names that change on every
save do not re-run the converter. The converter runs again when the stored
fingerprint no longer matches, or when you pass `--force-regenerate`. Cosmetic
manifest edits (key order, whitespace) do not trigger it.

Regeneration replaces the project: customizations made in
Xcode (entitlements, capabilities, added files or targets) are **discarded**.
Only these signing settings are preserved automatically: `DEVELOPMENT_TEAM`,
`CODE_SIGN_STYLE`, and `PROVISIONING_PROFILE_SPECIFIER`. Extension.js warns
before every regeneration of an existing project. If you customized the project
in Xcode, back it up first. Delete `dist/safari-xcode` for a clean slate.

### How the bundle id is enforced

Apple's converter derives the parent app's id from the app name instead of
taking `--bundle-identifier` verbatim. After every conversion, Extension.js
rewrites both `PRODUCT_BUNDLE_IDENTIFIER` entries in the generated
`project.pbxproj`: the app target gets your bundle id, and the extension target
gets `<bundle-id>.Extension`. This keeps the identity you configured, not the
one the converter guessed.

### What `xcodebuild` runs

The compile step uses the `Release` configuration with derived data written to
`dist/safari-xcode/.derived`, a folder worth adding to `.gitignore`. Signing
settings depend on `--development-team`. With a team id the build passes
`DEVELOPMENT_TEAM=<id>`, `CODE_SIGN_STYLE=Automatic`, and
`-allowProvisioningUpdates`, so Xcode mints the provisioning profile it needs
without being opened. Without one it passes ad-hoc settings
(`CODE_SIGN_IDENTITY=-`, `CODE_SIGNING_REQUIRED=NO`,
`CODE_SIGNING_ALLOWED=YES`) so the embedded `.appex` still validates without an
Apple Developer account. The scheme name is
your app name for a macOS-only project, and `<App> (macOS)` for a universal
project.

### Registration confirmation

After opening the app, Extension.js polls `pluginkit` for the extension's
registration, about 6 tries spread over 5 seconds, and prints a confirmation or
a not-yet-registered note. Under `--no-open` (and plain `build` without
`--open`) the app never launches, so registration cannot happen yet. The poll is
skipped and the CLI prints the `open` command instead.

## Reading logs

From 4.1.20, a Safari dev session is a normal session for [`logs`](/docs/commands/logs):

```bash theme={null}
extension logs --browser safari --follow
```

Background rows and content-script rows arrive in the same stream as they do on Chromium, with the same `--context`, `--level`, and `--output` filters.

A silent stream after an edit is the symptom described in the next section. It means the background context is gone, not that logging is unsupported.

## The one call that kills a Safari background

One unguarded Chromium-only call at the **top level** of a background script
throws on Safari. Safari then discards the background context, and you get no
logs, no reload, and no error anywhere. The extension looks dead.

```js theme={null}
// Kills the background on Safari: sidePanel does not exist there.
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
```

Guard the call, or branch at build time:

```js theme={null}
// Optional chaining is enough for a call you can skip.
chrome.sidePanel?.setPanelBehavior({ openPanelOnActionClick: true });

// Or keep the code out of the Safari bundle entirely.
if (import.meta.env.EXTENSION_PUBLIC_BROWSER !== "safari") {
  chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
}
```

The same code inside an event listener is survivable, because only that callback
throws. At the top level it takes the whole context with it. The build-time
warnings described below exist to catch exactly this, so read them before you
start debugging a background that never speaks.

See [Environment variables](/docs/features/environment-variables) for the
`EXTENSION_PUBLIC_BROWSER` value on each target.

## What Safari builds change in your manifest

### Background page instead of a service worker

From 4.1.20, Safari builds emit a non-persistent background **page** (`background: {scripts: [...]}`), not a service worker. Extension.js translates a `background.service_worker` entry into that shape, the same translation it runs for Firefox.

The reason is measured, not stylistic. On Safari 26.5.2, a Manifest V3 service
worker never started in testing, including for extensions built by Apple's own
converter. A background page ran immediately. WebKit deliberately prefers
the page form ([WebKit bug 270750](https://bugs.webkit.org/show_bug.cgi?id=270750)).

Author one `background.service_worker` and it loads on Chromium, Firefox, and
Safari. See [Background scripts](/docs/implementation-guide/background) for the
translation in both directions.

### Keys and permissions Safari cannot use

Manifest keys and permissions that Safari does not implement are dropped from the Safari build automatically. From 4.1.20, the build prints one line per dropped key, naming the key and why it went. Nothing is dropped silently, and your source `manifest.json` is untouched.

`content_scripts[].world` is deliberately **kept**, because Safari has supported it since Safari 18.

For Safari-only overrides, use the `safari:` and `webkit:` manifest prefixes. They win over the `chromium:` family keys that Safari otherwise inherits. See [Browser-specific manifest fields](/docs/features/browser-specific-fields).

### APIs Safari does not have

From 4.1.20, the build warns about extension APIs that Safari lacks, naming
each namespace or member it finds in your code. Twelve namespaces are covered:
`sidePanel`, `offscreen`, `tabGroups`, `management`, `identity`,
`notifications`, `bookmarks`, `history`, `downloads`, `idle`, `omnibox`, and
`userScripts`. The warnings run in production builds and in `dev`.

The build also warns on 21 members of namespaces that Safari does ship, where
the namespace resolves and the throw waits one level deeper:
`action.getUserSettings`, `action.getBadgeTextColor`,
`action.setBadgeTextColor`, `action.onUserSettingsChanged`, `storage.managed`,
`runtime.getContexts`, `runtime.onSuspend`, `runtime.onSuspendCanceled`,
`runtime.onUpdateAvailable`, `declarativeNetRequest.getAvailableStaticRuleCount`,
`declarativeNetRequest.getDisabledRuleIds`,
`declarativeNetRequest.updateStaticRules`,
`declarativeNetRequest.testMatchOutcome`,
`declarativeNetRequest.onRuleMatchedDebug`, `tabs.group`, `tabs.ungroup`,
`webNavigation.onCreatedNavigationTarget`,
`webNavigation.onHistoryStateUpdated`,
`webNavigation.onReferenceFragmentUpdated`, `webNavigation.onTabReplaced`, and
`windows.onBoundsChanged`.

The scan is textual. A mention of one of these names in a comment or in a
string literal triggers it too. Optional chaining silences it:
`chrome.sidePanel?.setPanelBehavior()` for a namespace, and
`chrome.tabs.group?.()` for a member.

A warning is not a build failure. Code that reaches one of these APIs
still ships. It is your call whether to guard it, branch on the target, or
accept that the feature is absent on Safari.

## Debugging in Safari

Web Inspector covers every extension context, and it is the right tool when you
want a debugger rather than a log stream:

* **Background**: Safari ▸ Develop ▸ Web Extension Background
  Content ▸ *your extension*.
* **Popup/options/sidebar pages**: open the surface, then right-click ▸ Inspect
  Element (or Develop ▸ *your Mac* ▸ the page).
* **Content scripts**: inspect the host page, where extension script contexts appear
  in the Sources tab under Extension Scripts.

If a build fails, the CLI prints the tail of the failing `xcrun`/`xcodebuild`
output, bounded to the last 50 lines and 8 KB so diagnostics stay readable.
Pass `--debug` (or set `EXTENSION_DEBUG=true`) to stream the full tool output
live instead.

## Engine target

`safari` has an engine alias, **`webkit-based`**, that parallels `chromium-based` and `gecko-based`:

```bash theme={null}
npx extension build --browser=webkit-based
```

## Command support

| Command | Safari support |
| - | - |
| `build` | ✅ Builds and packages the Safari app. |
| `dev` | ✅ Watch, incremental rebuild, logs, and automatic reload. |
| `preview` | ❌ Not supported. Exits with `E_COMMAND_UNSUPPORTED_FOR_TARGET`. |
| `start` | ❌ Not supported. Same reason as `preview`. |

`preview` and `start` exist to launch an already-built extension in a running browser. The Safari route for that is WebDriver, and it was measured. `safaridriver` does load an unpacked folder, and the background even runs, but Safari grants the extension **zero host origins**. Content scripts never inject, and no API call can grant the access afterwards. A session that looks healthy would test nothing, so both commands refuse Safari targets instead and point at `extension dev --browser safari` or `extension build --browser safari --open`.

<Frame>
  <iframe className="w-full aspect-video rounded-xl" src="https://www.youtube-nocookie.com/embed/AqmncRR_tSU?rel=0" title="Extension.js: extension dev --browser=safari" loading="lazy" allow="encrypted-media; picture-in-picture; fullscreen" allowFullScreen />
</Frame>

## Limitations

* **Packaging is macOS only.** The Xcode step needs the full Xcode app. `build` still produces `dist/safari` elsewhere.
* **`preview` and `start` have no Safari path.** See [Command support](#command-support) for what was measured.
* **Enabling and granting access are manual.** They are Safari security controls and cannot be automated. Both survive restarts.
* **Signing stops at development.** `--development-team` signs the local app with your development certificate. Distribution signing, notarization, and App Store submission are a separate step beyond this workflow (see below).
* **macOS target only.** iOS app generation is not produced by this workflow today.

## After dev: shipping to the App Store

The Safari workflow above ends with a locally signed app, ad-hoc or development. Distributing it
(to the Mac App Store, or as a notarized direct download) is a separate
pipeline that Extension.js plans to offer through the
[extension.dev](https://extension.dev?utm_source=extension-js-org\&utm_medium=sponsor\&utm_campaign=docs-seam) platform. Until that lands, follow
Apple's own guides:

* [Distributing your Safari web extension](https://developer.apple.com/documentation/safariservices/safari_web_extensions/distributing_your_safari_web_extension) (Apple Developer Program, signing, App Store Connect).
* [Notarizing macOS software](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution) for non-App Store distribution.

Two things from the Extension.js side make that path smooth. Do them early:

1. **Set your own `--bundle-id`** (reverse-DNS, a domain you own) from the first
   build. Bundle id is the extension's identity on Apple platforms.
2. **Set your `DEVELOPMENT_TEAM` in Xcode once**: it survives project
   regeneration automatically, along with `CODE_SIGN_STYLE` and
   `PROVISIONING_PROFILE_SPECIFIER`.

## What a session leaves behind

The ready contract at `dist/extension-js/safari/ready.json` carries Safari-specific values:

| Field | Value on Safari |
| - | - |
| `browserPid` | The container app's process id. With `--safari-binary`, Safari's own process id. |
| `extensionId` | `<bundle id>.Extension`, the id of the extension target inside the app. |
| `runtime` | `attached` once the extension's background connects to the dev server, `detached` after it disconnects. |
| `binaryProvenance` | `system` for the container app, `pinned` with `--safari-binary`. |

`extension logs`, `extension eval`, `extension reload`, and `extension doctor --browser safari` attach to the session through that contract, the same way they do on Chromium.

Ctrl+C stops the dev server only. The container app stays open, Safari stays open, the extension stays registered with macOS and enabled in Safari Settings, and `dist/safari-xcode/` stays on disk. The next `dev` reuses that project. Delete `dist/safari-xcode/` for a clean slate, which makes the next run convert and build from scratch.

## Best practices

* **Read the build warnings first.** They name the APIs and manifest keys that Safari does not have, which is the cheapest way to find a background that will not start.
* **Guard Chromium-only calls at the top level** of background and content scripts, with optional chaining or a branch on `EXTENSION_PUBLIC_BROWSER`.
* **Use browser-specific fields** for true behavioral differences. Safari resolves the `chromium:` family prefix, and `safari:`/`webkit:` prefixed keys win over it on Safari targets, for both `--browser=safari` and `--browser=webkit-based`. `chrome:` and `edge:` keys do not reach Safari.
* **Keep the generated project** unless you need a clean slate, because regenerating discards Xcode-side customizations beyond the preserved signing settings.

## Next steps

* See all [supported browsers](/docs/browsers/browsers-available).
* Stream a Safari session with [`logs`](/docs/commands/logs).
* Use [browser-specific manifest fields](/docs/features/browser-specific-fields).
* Review [multi-platform builds](/docs/features/multi-platform-builds).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.