Skip to main content
Develop a web extension against Safari on macOS, with no separate Xcode project to maintain by hand.
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.
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.
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.

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.
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. 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: 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.
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.
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).

App identity and packaging options

Both dev and build accept identity overrides (Safari targets only): 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):
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:
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:
  • 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:
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.
Guard the call, or branch at build time:
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 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). Author one background.service_worker and it loads on Chromium, Firefox, and Safari. See Background scripts 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.

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:

Command support

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.

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 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 platform. Until that lands, follow Apple’s own guides: 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: 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