Getting your Chrome extension onto Safari and iPhone: the complete guide

At ZeroClick I owned Safari and iOS for Pie, an ad blocker with more than two million users. Pie shipped first as a Chrome extension, and no Chrome extension reaches an iPhone, so the Safari port was how the product got to the phone. Apple ships a command-line tool that converts a Chrome extension into a Safari one. We ran it and got an Xcode project that compiled on the first try.
The build was the easy part. Pie’s blocking engine was written against an API that Safari accepts without error and quietly ignores, so every ad request would have sailed through untouched. The rules we rebuilt it on brought their own trouble: some installed cleanly and never matched anything, and one bad combination blanked all of Spotify.
You may be holding the same shape of problem: a working Chrome extension, a reason to be on Safari or iPhone, and a converter that looks like it does the whole job in one command. That first clean build is where most teams get the wrong idea about how the rest of the port will go.
Ports like this became possible only recently. Safari has supported the WebExtension API, the same standard Chrome extensions are built on, since Safari 14, and since iOS 15 the iPhone runs it too. So every Chrome team now has a plausible Safari port on the table. The catch is that Safari runs the shared standard on top of a stricter privacy and process model, and the differences fail silently instead of throwing.
There are two ways to run a port like this. You can translate the extension as it stands and patch whatever breaks, or you can audit every API it touches and redesign around Safari’s model where the two diverge. The redesign is what shipped Pie. Blocking moved to declarative rules, the lists you hand the browser so its network layer does the enforcement. The background script was rewritten to survive suspension, and signing moved into CI during the first week.
Translate-and-patch fails in specific, repeatable places, and the body of this guide covers each one. I’d been through this once before, at Honey, where I built the company’s first iOS browser extension and ported the legacy Safari extension to Apple’s modern API. This guide is the map I wish I’d had both times.
What the converter does
Safari has supported the WebExtension API, the same standard Chrome extensions are built on, since Safari 14. The converter’s job is to wrap your existing extension in the packaging Safari requires:
xcrun safari-web-extension-converter /path/to/your-extensionThat produces an Xcode project containing a small native app with your extension embedded inside it. Notice the first structural difference from Chrome: a Safari extension is not a standalone artifact you upload to a store. It ships inside a Mac or iOS app and gets enabled in Safari’s settings.
On iOS the app comes from the App Store. On macOS it comes from the App Store or, since Safari 18.4, from direct distribution as a notarized app, one Apple has scanned and stamped, signed with your Developer ID, the certificate tied to your Apple developer account.
Now for the part the clean build hides. Xcode compiles the Swift wrapper, never your JavaScript, so the extension installs and shows up in Safari’s settings while individual features fail silently at runtime, with nothing in the build log pointing at them. The converter copies your JavaScript, manifest, and assets, but it does not check whether the APIs you call exist in Safari. You answer that question API by API.
What breaks, by category
1. Request blocking and modification
This is the big one, and it announces itself quietly. A blocking webRequest listener that cancels ad requests in Chrome registers without error in Safari. Then every request sails through untouched. Safari ignores the blocking option and never consults your return value, so ads load while your listener code appears to run.
Blocking webRequest does not exist in Safari. Safari’s model is declarativeNetRequest, DNR from here on: you hand the browser a list of rules up front, and the browser’s network layer applies them without your JavaScript ever seeing a request. Safari’s process model and privacy stance never allowed extension code to intercept requests synchronously.
// Registers without error in Safari. Also does nothing there:
// the 'blocking' option is ignored and the return value is never
// read, so this code "runs" while every request goes through.
browser.webRequest.onBeforeRequest.addListener(
(details) => {
if (isAd(details.url)) return { cancel: true };
},
{ urls: ['<all_urls>'] },
['blocking'],
);
// Safari's blocking primitive: hand the browser a rule and let its
// network layer evaluate it. Your code never sees the request.
await browser.declarativeNetRequest.updateDynamicRules({
addRules: [
{
id: 1,
action: { type: 'block' },
condition: {
urlFilter: '||ads.example.com',
resourceTypes: ['script', 'xmlhttprequest'],
},
},
],
});For an ad blocker, privacy tool, or anything with custom filtering, that is a redesign. Dynamic blocking logic gets re-expressed as rule add and remove operations, and anything that inspected requests at runtime needs a different mechanism or gets cut. On Pie, the entire blocking engine became DNR, with the filter lists that define what to block compiled into rules and injected page scripts handling what rules can’t express. I wrote more about this migration in the webRequest article.
2. Background script lifecycle
The second category shows up as decay. The extension works for a few minutes after you enable it, then features stop. Alarms fire into a dead context, an open WebSocket is silently gone, and in-memory caches come back empty. The failures look random because they depend on when Safari suspended your background script, the long-running context that holds your extension’s logic.
Safari on iOS does not run persistent background pages, and you shouldn’t rely on them on macOS either. macOS still permits one for Manifest V2 extensions, but memory pressure on the phone means Safari aggressively suspends and kills background contexts, so any extension targeting both platforms lives without persistence.
The fix is the discipline Manifest V3 pushed on Chrome: event-driven code, durable state in extension storage, nothing important living only in memory. If you already survived Chrome’s MV3 migration, you’ve done most of this work. Pie shipped this exact shape on Safari as a non-persistent background page, Safari’s alternative to Chrome’s service worker.
// Assume the background context can die between any two events.
// Rehydrate from storage in every handler instead of trusting
// module-level variables to still be there.
browser.runtime.onMessage.addListener(async (message) => {
if (message.type !== 'pause-domain') return;
const { paused = [] } = await browser.storage.local.get('paused');
await browser.storage.local.set({
paused: [...paused, message.domain],
});
});
// Timers are the classic casualty. A setTimeout dies with the
// suspended page; an alarm fires even if Safari has to wake the
// background context to deliver it.
browser.alarms.create('refresh-rules', { periodInMinutes: 60 });3. API coverage gaps
The third category is the one that eats weeks. Safari implements a large subset of the WebExtension API, and the costly pattern is an API that exists as a property, so feature detection passes, but does nothing or behaves subtly differently. Nothing throws. You find out from behavior on real sites.
DNR is where this bit us hardest on Pie. Rules registered with zero console errors and showed up when we listed them, but some never matched anything, and one condition combination over-matched badly enough to blank all of Spotify. Safari read an older key name where Chrome used initiatorDomains, refused the domain-filtered allowAllRequestsrules we depended on, which exempt a page’s requests from blocking, and mishandled redirect actions.
We shipped a translation pass that rewrote every rule set before install, plus a runtime layer that detected invalid installed rules and repaired them in place. I covered that system in the dynamic rules article. We also had to polyfill cookie-change detection that Safari lacks. The only answer you can trust comes from auditing every API your extension touches against the Safari versions you intend to support. That audit is the first deliverable of a port assessment.
4. The native layer nobody on a JS team signed up for
Past the JavaScript, you now own an Xcode project: bundle identifiers, entitlements (the capabilities Apple grants your app), provisioning profiles (the files tying your app to your certificates and devices), code signing, and a containing app that Apple expects to do more than exist. None of it is hard for someone who does it weekly. All of it is new to a JavaScript team.
Here’s how the stall looks in practice. The extension runs fine in one engineer’s Xcode. Any archive built anywhere else fails signing, or produces an app whose extension won’t load on another machine. The signing material lives in that one developer’s keychain by default, which is knowledge no WebExtension workflow ever required.
Set up CI with real signing during the first week of the port. Both mainstream routes work. Pie ran Xcode Cloud, with a post-clone script installing the Node toolchain so cloud builds matched local ones. I’ve built the same flow on GitHub Actions with an isolated build keychain and a TestFlight upload step. Either one makes the whole class of problem go away.
iOS: the same codebase, a different product
Since iOS 15, iPhones and iPads run real Safari Web Extensions: the same WebExtension code, in the same wrapper project. This is the single biggest reason to port, since your competitors’ Chrome extensions have no presence on the phone at all.
Treat iOS as its own target anyway. The port that passes QA on desktop Safari can ship to an iPhone and render its popup wrong, or misfire its messaging to the background, because the iOS popover is a different surface than the desktop popup the code was written against.
- The popup you designed for a desktop window becomes a popover on a 390-pixel phone, with its own lifecycle.
- Touch replaces hover, so anything revealed on hover needs another trigger.
- Some APIs available on macOS are missing or behave differently, and the whole windowing model differs.
- The containing app faces iOS App Review, which is stricter than the Mac’s.
Keep one manifest and one JavaScript bundle with two thin native targets, and give iOS its own QA pass on a physical device. That’s the structure Pie shipped: shared code, small per-platform accommodations like a mobile-optimized ruleset, and no fork. The simulator won’t catch everything.
App Store review, for extension people
Your extension ships inside an app, so it goes through App Review. The first submission of a converted extension often bounces within 24 to 48 hours, and each fix-and-resubmit round trip costs another review cycle. Here are the rejections I’ve seen most, at Honey and Pie:
- The containing app does too little. Apple wants it to have some purpose: at minimum, clear instructions and state for enabling the extension. A blank window gets flagged.
- Permissions come without explanation. If your extension wants access to all websites, the review notes need to say why in plain language, and the app should explain it to users too.
- The privacy labels don’t match reality. Your App Store privacy questionnaire has to agree with what the extension collects, and an analytics SDK somebody forgot about is the classic trap.
- The metadata mentions other browsers. Keep the store listing about the Safari product.
Review itself usually takes a day or two now. Thorough review notes, written before the first submission, are the cheapest way out of the rejection loop.
Maintaining both after launch
The port isn’t the end state; two storefronts are. Keep one WebExtension codebase with a thin, well-isolated Safari layer, feature-detect instead of forking, and automate the Apple release path. Shipping to Safari should never depend on the one person with a working Xcode setup. Budget for Safari’s annual changes too: new macOS and iOS versions move extension behavior more than Chrome updates do.
When not to port
“Should we even port this?” you ask. Sometimes the answer is no, and that’s a legitimate outcome. If your extension’s core value depends on capabilities Safari deliberately doesn’t offer, such as deep request inspection or Chrome-only surfaces, a port produces a worse product under your brand.
Some extensions shouldn’t be ported at all. Going in, expect that answer for roughly one in five. Finding out costs a week and saves a quarter.
The short version
- The converter’s clean first build proves the packaging works and nothing else.
- Blocking
webRequestis the most common redesign, and background-script suspension is the most common source of bugs that look random. - iOS is the prize, and it’s a real second target.
- The Apple toolchain of signing, CI, and App Review is where JavaScript teams lose the most time. It’s also the easiest part to hand to someone who lives there.
Questions clients ask
Can every Chrome extension be ported to Safari?
Most can, but not all as a straight translation. Extensions built on blocking webRequest, complex background lifecycles, or Chrome-only APIs need parts redesigned around Safari’s model. A small number shouldn’t be ported at all: the ones whose core feature depends on something Safari deliberately doesn’t allow. It’s better to find that out in week one.
Do I need a Mac and an Apple Developer account?
Yes to both. The converter and Xcode only run on macOS, and you need an Apple Developer Program membership at $99 per year. On iOS, distribution is App Store only. On macOS, Safari 18.4 added a second path: a notarized app signed with your Developer ID, distributed outside the store. Budget a few days for Apple to approve a new developer account.
How long does a real port take?
Plan on 4–6 weeks to shipped-and-approved for a simple extension with no request blocking. Plan on 6–10 weeks for a content blocker or anything built on webRequest, because the blocking layer has to be rebuilt around declarative rules rather than translated. Those are planning ranges, and the spread is exactly why I audit before quoting.
Does the same code run on macOS and iOS Safari?
Largely yes. Safari Web Extensions share one WebExtension codebase across macOS and iOS, wrapped in a single app project. But iOS has real differences: the popup becomes a popover with its own lifecycle, some APIs are unavailable or behave differently, and App Review is stricter. Treat iOS as its own QA target and test on a physical device.
More guides
- Safari has no blocking webRequest. Here's what to do instead.The declarativeNetRequest migration: what survives, what needs redesign.
- The converter ran fine. So why is your extension broken?Six silent failure modes of converted extensions and how to diagnose each.
- Safari extension rejected? The usual reasons, and the fixes.App Review rejections translated into fixes, plus review notes that pass.
Want the answer for your extension specifically?
The Safari Port Assessment runs everything in this guide against your actual codebase: a full compatibility catalog, a DNR migration plan, and a fixed quote. It takes one week and costs $2,500.