Skip to main content
Safari Web Extensions allow you to customize the Safari browsing experience. They can modify web pages, add toolbar buttons, and interact with the browser using the Web Extensions API.
Safari extensions use standard Web Extensions APIs compatible with Chrome and Firefox extensions, with some Safari-specific features.

Getting Started

1

Generate the Safari extension

npx create-target safari
This creates targets/safari/ with:
  • expo-target.config.js — Target configuration
  • SafariWebExtensionHandler.swift — Native message handler
  • assets/ — Web extension files (HTML, CSS, JavaScript)
2

Configure the target

targets/safari/expo-target.config.js
/** @type {import('@bacons/apple-targets/app.plugin').Config} */
module.exports = {
  type: "safari",
  icon: "../../assets/icon.png",
  deploymentTarget: "15.0",
};
3

Run prebuild

npx expo prebuild -p ios --clean
4

Enable the extension

Run your app, then go to Safari Settings > Extensions and enable your extension.

Extension Structure

The generated extension includes these files:
targets/safari/assets/
├── manifest.json          # Extension manifest
├── background.js          # Background script
├── content.js            # Content script (runs on web pages)
├── popup.html            # Toolbar button popup
├── popup.js              # Popup script
├── popup.css             # Popup styles
├── images/               # Extension icons
└── _locales/en/          # Localization
    └── messages.json

Manifest File

targets/safari/assets/manifest.json
{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0",
  "description": "A Safari Web Extension",
  
  "icons": {
    "48": "images/icon-48.png",
    "96": "images/icon-96.png",
    "128": "images/icon-128.png",
    "256": "images/icon-256.png"
  },
  
  "action": {
    "default_popup": "popup.html",
    "default_title": "My Extension",
    "default_icon": {
      "16": "images/toolbar-icon-16.png",
      "19": "images/toolbar-icon-19.png",
      "32": "images/toolbar-icon-32.png",
      "38": "images/toolbar-icon-38.png"
    }
  },
  
  "background": {
    "service_worker": "background.js"
  },
  
  "content_scripts": [
    {
      "matches": ["*://*/*"],
      "js": ["content.js"]
    }
  ],
  
  "permissions": [
    "activeTab",
    "storage"
  ]
}

Content Scripts

Content scripts run in the context of web pages:
targets/safari/assets/content.js
// Run when the page loads
(function() {
    'use strict';
    
    console.log('Extension loaded on:', window.location.href);
    
    // Example: Highlight all links
    const links = document.querySelectorAll('a');
    links.forEach(link => {
        link.style.outline = '2px solid red';
    });
    
    // Listen for messages from background script
    browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
        if (request.action === 'highlightLinks') {
            highlightLinks(request.color);
            sendResponse({ success: true });
        }
        return true;
    });
    
    function highlightLinks(color) {
        const links = document.querySelectorAll('a');
        links.forEach(link => {
            link.style.backgroundColor = color;
        });
    }
})();

Background Scripts

Background scripts handle extension logic:
targets/safari/assets/background.js
// Listen for extension installation
browser.runtime.onInstalled.addListener(() => {
    console.log('Extension installed');
    
    // Set default storage values
    browser.storage.local.set({
        highlightColor: '#ffff00',
        enabled: true
    });
});

// Listen for toolbar button clicks
browser.action.onClicked.addListener((tab) => {
    // Send message to content script
    browser.tabs.sendMessage(tab.id, {
        action: 'highlightLinks',
        color: '#ffff00'
    });
});

// Listen for messages from content scripts
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
    console.log('Received message:', request);
    
    if (request.action === 'getData') {
        browser.storage.local.get('highlightColor').then(result => {
            sendResponse({ color: result.highlightColor });
        });
        return true; // Keep channel open for async response
    }
});
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="popup.css">
</head>
<body>
    <div class="container">
        <h1>My Extension</h1>
        <p>Customize your browsing experience</p>
        
        <div class="setting">
            <label for="color">Highlight Color:</label>
            <input type="color" id="color" value="#ffff00">
        </div>
        
        <button id="apply">Apply</button>
        <button id="reset">Reset</button>
    </div>
    
    <script src="popup.js"></script>
</body>
</html>

Native Messaging

Communicate between your web extension and native Swift code:

Swift Handler

targets/safari/SafariWebExtensionHandler.swift
import SafariServices
import os.log

class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {

    func beginRequest(with context: NSExtensionContext) {
        let item = context.inputItems[0] as! NSExtensionItem
        let message = item.userInfo?[SFExtensionMessageKey]
        os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@", message as! CVarArg)

        let response = NSExtensionItem()
        response.userInfo = [ SFExtensionMessageKey: [ "Response to": message ] ]

        context.completeRequest(returningItems: [response], completionHandler: nil)
    }

}

JavaScript Caller

// Send message to native code
browser.runtime.sendNativeMessage('com.yourapp.safari', {
    action: 'getData',
    key: 'someKey'
}, response => {
    console.log('Native response:', response);
});

Permissions

Common permissions for Safari extensions:
{
  "permissions": [
    "activeTab",        // Access current tab
    "storage",          // Use browser.storage API
    "tabs",             // Query and manage tabs
    "webRequest",       // Intercept network requests
    "webNavigation",    // Monitor page navigation
    "contextMenus"      // Add context menu items
  ],
  
  "host_permissions": [
    "*://*/*"           // Access all websites
  ]
}

Content Security Policy

For Manifest V3, configure CSP:
{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self';"
  }
}

Debugging

Enable Web Inspector

  1. On iOS: Settings > Safari > Advanced > Web Inspector
  2. On Mac: Safari > Settings > Advanced > Show Develop menu

Debug Content Scripts

  1. Open Safari and navigate to a page
  2. Right-click and select “Inspect Element”
  3. Go to the Sources tab to see your content scripts

Debug Background Scripts

  1. Open Safari > Develop > Web Extension Background Content
  2. Select your extension from the list

Debug Popup

  1. Right-click your extension’s toolbar icon
  2. Select “Inspect Popup”

Testing

1

Run your app

Build and run your app in Xcode or on a device.
2

Enable the extension

  • iOS: Settings > Safari > Extensions > Your Extension (toggle on)
  • macOS: Safari > Settings > Extensions > Your Extension (check the box)
3

Test functionality

Open Safari and navigate to a website to test your content scripts.

Distribution

Safari extensions must be distributed through the App Store as part of an iOS or macOS app.
Your extension is packaged with your app and automatically available when users install your app.

Localization

Add translations in assets/_locales/:
assets/_locales/
├── en/
│   └── messages.json
├── es/
│   └── messages.json
└── fr/
    └── messages.json
_locales/en/messages.json
{
  "extensionName": {
    "message": "My Extension"
  },
  "extensionDescription": {
    "message": "A great Safari extension"
  }
}
Reference in manifest.json:
{
  "name": "__MSG_extensionName__",
  "description": "__MSG_extensionDescription__"
}

Best Practices

Keep it lightweight: Content scripts run on every page load. Minimize their impact on page performance.
Use storage wisely: Store settings in browser.storage.local or browser.storage.sync (syncs across devices).
Respect privacy: Only request permissions you actually need. Users can see and must approve all permissions.

Next Steps

Build docs developers (and LLMs) love