Create web extensions for Safari
Build, test, package, and distribute Safari web extensions with Manifest V3, declarativeNetRequest, content scripts, storage, and native messaging.
TL;DR
- Safari web extensions are built with HTML, CSS, and JavaScript, run across Safari on iOS, iPadOS, macOS, and visionOS, and can be loaded temporarily in Safari for development without Xcode.
- Use Manifest V3 plus WebExtensions APIs such as declarativeNetRequest, scripting, permissions, storage, and background pages or service workers to block, redirect, modify pages, and persist state.
- Safari's permissions model puts users in control of host access; optional host permissions let extensions request access at runtime only for sites the user adds.
- Extensions can be packaged through App Store Connect or Xcode, distributed with TestFlight and the App Store, and connected to native app capabilities through native messaging.
Safari web extension basics
Safari web extensions are packaged inside an app but authored with standard web technologies: HTML, CSS, and JavaScript. The session builds a distraction-blocking extension that works across Safari on iOS, iPadOS, macOS, and visionOS.
Every extension starts with a Manifest V3 manifest.json file that identifies the extension and declares its capabilities. Safari can load an unpacked extension folder for development after enabling web developer features and allowing unsigned extensions in Safari Settings.
- Use the manifest for name, description, version, icons, UI entry points, permissions, host permissions, background scripts, and content scripts.
- Add an SVG icon so Safari can scale it for toolbar and Extensions Settings contexts.
- Use an action popup for compact toolbar UI, or an options page for larger settings UI.
Minimal Manifest V3 file
The manifest is the required JSON file that tells Safari what the extension is.
{
"manifest_version": 3,
"name": "Shiny OnTrack",
"description": "Stay on track while you browse the web",
"version": 1.0
}Options page UI entry point
An options page gives the extension a full-page settings surface instead of a small toolbar popup.
{
"manifest_version": 3,
"name": "Shiny OnTrack",
"description": "Stay on track while you browse the web",
"version": 1.0,
"icons": { "512": "images/icon.svg" },
"options_ui": { "page": "options.html" }
}Blocking and redirecting network requests
The extension uses the declarativeNetRequest API to block, modify, or redirect network requests. Rules include an ID, priority, action, and matching condition. Static rules can be declared up front, but dynamic rules are useful when the sites are chosen by the user at runtime.
Blocking a navigation does not require page access, but redirecting a network request does. For redirects, the extension switches to declarativeNetRequestWithHostAccess and asks for optional host permissions when the user adds a site.
- Use
declarativeNetRequestfor simple block rules that do not need host access. - Use
declarativeNetRequestWithHostAccesswhen rules need host access, such as redirecting to an extension page. - Declare
optional_host_permissionswhen access should be requested lazily at runtime. - Request both the host and subdomain origins when adding a user-selected domain.
Dynamic redirect rule
Redirect rules can send blocked navigations to a custom extension page instead of Safari's default blocked/error page.
function createRedirectRule(host) {
return {
id: hostToRuleID(host),
priority: 1,
action: {
type: "redirect",
redirect: { extensionPath: "/blocked.html" }
},
condition: {
urlFilter: `||${host}`,
resourceTypes: ["main_frame"]
}
}
}
await browser.declarativeNetRequest.updateDynamicRules({
addRules: hosts.map(createRedirectRule)
})Request optional host access at runtime
Optional host permissions let the extension ask for access only when the user adds a site.
const granted = await browser.permissions.request({
origins: [`*://${host}/*`, `*://*.${host}/*`]
})
if (!granted) returnModifying webpages with content scripts
Content scripts let an extension read and modify a page. The sample uses them to inject a 10-minute countdown timer on distracting sites when the user selects the light blocking mode.
Content scripts can be static in the manifest when match patterns are known ahead of time, or registered dynamically with the scripting API when the target hosts are user-defined. Dynamically registered scripts can persist across Safari relaunches, but the session notes that they should be re-registered after extension updates.
- Add the
scriptingpermission to dynamically register content scripts. - Use match patterns for both the selected domain and its subdomains.
- Set
persistAcrossSessions: trueto keep registered scripts after Safari relaunches. - Use a background page or service worker to respond to lifecycle events such as extension updates.
Register a persistent content script dynamically
Dynamic registration is appropriate when the extension does not know target sites until the user adds them.
function contentScript(host) {
return {
id: `cs-${host}`,
js: ["content.js"],
css: ["content.css"],
matches: [`*://${host}/*`, `*://*.${host}/*`],
persistAcrossSessions: true
}
}
await browser.scripting.registerContentScripts(hosts.map(contentScript))Re-register content scripts after updates
Registered content scripts persist across Safari restarts but should be restored after extension updates.
browser.runtime.onInstalled.addListener(async (details) => {
if (details.reason !== "update") return
const hosts = await getHosts()
await registerScripts(hosts)
})Persisting settings and extension state
The sample initially stores blocklist state in memory, which disappears when the extension reloads. The storage API fixes this by persisting hosts and blocking mode in browser.storage.local. Safari also supports session storage for temporary in-memory data that does not need to survive restarts.
- Use
browser.storage.localfor durable extension preferences and user state. - Use session storage for short-lived data that should not be written to disk.
- Store both the list of blocked hosts and the selected blocking mode.
- When the mode changes, update stored state and recreate or remove blocking rules accordingly.
Storage helpers for hosts and mode
The extension persists its blocklist and selected mode with browser.storage.local.
export async function updateHosts(hosts) {
await browser.storage.local.set({ hosts })
}
export async function getHosts() {
const { hosts = [] } = await browser.storage.local.get("hosts")
return hosts
}
export async function saveBlockMode(mode) {
await browser.storage.local.set({ blockMode: mode })
}
export async function getBlockMode() {
const { blockMode = "full" } = await browser.storage.local.get("blockMode")
return blockMode
}Packaging, TestFlight, and App Store distribution
Safari web extensions must be packaged inside a containing app. The session shows two packaging paths: using App Store Connect to create/package the app without Xcode, or using Xcode after generating a project with the Safari Web Extension Packager.
In App Store Connect, the developer creates an app, chooses supported platforms, sets a bundle identifier, uploads the extension resources through the Safari Web Extension Packager, tests with TestFlight, and submits the build for App Review. Choosing iOS and macOS makes the extension available on iPhone, iPad, Mac, and as a compatible app on Apple Vision Pro.
- Use TestFlight to distribute beta builds and collect feedback before App Store submission.
- For Xcode-based distribution, archive the app and ensure the build number is higher than any previously uploaded build.
- Use App Store Connect's distribution metadata, screenshots, description, selected build, and review submission workflow.
Generate an Xcode project from extension resources
The Safari Web Extension Packager creates and opens an Xcode project containing the app and web extension.
xcrun safari-web-extension-packager --copy-resources /path/to/ShinyOnTrackNative messaging with the containing app
Native messaging lets JavaScript in the web extension communicate with the containing app through a Safari app extension handler. This is useful when the extension needs platform features unavailable to web APIs.
The sample adds biometric authentication before allowing changes to the blocklist. The extension sends a requestBioAuth message from its background page, and the generated SafariWebExtensionHandler uses Local Authentication to evaluate biometric authentication and return a success value.
- Declare the
nativeMessagingpermission in the manifest. - Send messages from extension JavaScript with
browser.runtime.sendNativeMessage. - Handle incoming messages in
SafariWebExtensionHandler, usingSFExtensionMessageKeyto read and reply. - Use native APIs such as
LocalAuthenticationinside the containing app or app extension side of the flow.
Send a native message from the extension
The background page asks the native side to perform biometric authentication and returns whether it succeeded.
export async function requestBioAuth() {
const message = { message: "requestBioAuth" }
const response = await browser.runtime.sendNativeMessage(message)
return response?.success
}Reply from SafariWebExtensionHandler
The app extension returns a message payload to the web extension through SFExtensionMessageKey.
private func reply(context: NSExtensionContext, success: Bool) {
let response = NSExtensionItem()
response.userInfo = [SFExtensionMessageKey: ["success": success]]
context.completeRequest(returningItems: [response], completionHandler: nil)
}Resources
- w3.org - W3C WebExtensions Community Group
- Packaging and distributing Safari Web Extensions with App Store Connect
- WebKit.org - Report issues to the WebKit open-source project
- Submit feedback
- MDN Web Docs - Web Extensions API
Related Sessions
Get started with the HTML Model Element
Use the native HTML <model> element to embed USDZ 3D content in Safari, add fallbacks and interactions, enable AR, and optimize assets for production.
Learn CSS Grid Lanes
Use CSS Grid Lanes to build masonry and brick-wall layouts in Safari with familiar Grid syntax, item spanning, subgrid, and flow-tolerance.