How to implement a content blocker in SwiftUI
A Safari Content Blocker is an app extension whose sole job is returning a JSON rule list via
NSExtensionRequestHandling. Your main SwiftUI app calls
SFContentBlockerManager.reloadContentBlocker(withIdentifier:)
whenever the rules change so Safari picks them up immediately.
// ContentBlockerRequestHandler.swift (extension target)
import SafariServices
final class ContentBlockerRequestHandler: NSObject, NSExtensionRequestHandling {
func beginRequest(with context: NSExtensionContext) {
guard let url = Bundle.main.url(forResource: "blockerList",
withExtension: "json"),
let attachment = NSItemProvider(contentsOf: url) else {
context.cancelRequest(withError: URLError(.fileDoesNotExist))
return
}
let item = NSExtensionItem()
item.attachments = [attachment]
context.completeRequest(returningItems: [item])
}
}
Full implementation
The implementation spans three files: a blockerList.json bundled inside
the extension, the ContentBlockerRequestHandler that hands it to Safari,
and a SwiftUI ContentBlockerSettingsView in the host app that lets
users reload or inspect the current rule count. The extension bundle identifier
(e.g., com.example.MyApp.ContentBlocker) ties all three together.
// ──────────────────────────────────────────────
// 1. blockerList.json (add to extension target)
// ──────────────────────────────────────────────
// [
// {
// "trigger": { "url-filter": ".*", "resource-type": ["script"],
// "if-domain": ["ads.example.com"] },
// "action": { "type": "block" }
// },
// {
// "trigger": { "url-filter": "ad-banner\\.png" },
// "action": { "type": "block" }
// },
// {
// "trigger": { "url-filter": ".*", "resource-type": ["image"],
// "if-domain": ["tracker.io"] },
// "action": { "type": "block-cookies" }
// }
// ]
// ──────────────────────────────────────────────
// 2. ContentBlockerRequestHandler.swift (extension target)
// ──────────────────────────────────────────────
import SafariServices
final class ContentBlockerRequestHandler: NSObject, NSExtensionRequestHandling {
func beginRequest(with context: NSExtensionContext) {
guard let url = Bundle.main.url(forResource: "blockerList",
withExtension: "json"),
let attachment = NSItemProvider(contentsOf: url) else {
context.cancelRequest(withError: URLError(.fileDoesNotExist))
return
}
let item = NSExtensionItem()
item.attachments = [attachment]
context.completeRequest(returningItems: [item])
}
}
// ──────────────────────────────────────────────
// 3. ContentBlockerSettingsView.swift (host app target)
// ──────────────────────────────────────────────
import SwiftUI
import SafariServices
private let blockerID = "com.example.MyApp.ContentBlocker"
struct ContentBlockerSettingsView: View {
@State private var isEnabled: Bool = false
@State private var ruleCount: Int = 0
@State private var isReloading = false
@State private var statusMessage = ""
var body: some View {
List {
Section {
HStack {
Label("Content Blocker", systemImage: "shield.fill")
Spacer()
Image(systemName: isEnabled ? "checkmark.circle.fill" : "xmark.circle")
.foregroundStyle(isEnabled ? .green : .secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Content Blocker is \(isEnabled ? "enabled" : "disabled")")
if ruleCount > 0 {
Text("\(ruleCount) active rules")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Status")
} footer: {
Text("Enable the extension in Settings → Safari → Extensions.")
}
Section {
Button {
reload()
} label: {
HStack {
Label("Reload Rules", systemImage: "arrow.clockwise")
Spacer()
if isReloading {
ProgressView()
}
}
}
.disabled(isReloading)
if !statusMessage.isEmpty {
Text(statusMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Content Blocker")
.task { await checkStatus() }
}
// MARK: – Helpers
private func reload() {
isReloading = true
statusMessage = ""
SFContentBlockerManager.reloadContentBlocker(withIdentifier: blockerID) { error in
Task { @MainActor in
isReloading = false
if let error {
statusMessage = "Reload failed: \(error.localizedDescription)"
} else {
statusMessage = "Rules reloaded successfully."
await checkStatus()
}
}
}
}
@MainActor
private func checkStatus() async {
await withCheckedContinuation { continuation in
SFContentBlockerManager.getStateOfContentBlocker(
withIdentifier: blockerID
) { state, _ in
Task { @MainActor in
isEnabled = state?.isEnabled ?? false
ruleCount = loadRuleCount()
continuation.resume()
}
}
}
}
private func loadRuleCount() -> Int {
guard let url = Bundle.main.url(forResource: "blockerList", withExtension: "json"),
let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else { return 0 }
return json.count
}
}
#Preview {
NavigationStack {
ContentBlockerSettingsView()
}
}
How it works
-
blockerList.json — Each element in the array is a rule with a
trigger(URL pattern, resource type, domain filters) and anaction(block,block-cookies,css-display-none,ignore-previous-rules). Safari compiles these into a binary format once; recompilation only happens when you callreloadContentBlocker. -
ContentBlockerRequestHandler.beginRequest(with:) — Safari
calls into your extension at compile time. You must return exactly one
NSExtensionItemwhose single attachment is anNSItemProviderwrapping the JSON file URL. Any error causes Safari to use the previously compiled rule set (or no rules on first load). -
SFContentBlockerManager.reloadContentBlocker(withIdentifier:)
— Calling this from the host app triggers Safari to re-invoke your extension
and recompile the JSON. The identifier must exactly match the extension's
CFBundleIdentifierin its Info.plist. -
SFContentBlockerManager.getStateOfContentBlocker(withIdentifier:)
— Returns an
SFContentBlockerStatewhoseisEnabledproperty reflects whether the user has toggled the extension on in Settings → Safari → Extensions. Your UI should react to this; you can't enable it programmatically. -
SwiftUI
.taskmodifier — Used to asynchronously fetch the extension state when the view appears, keeping the UI in sync without blocking the main thread.
Variants
Dynamic rules generated at runtime
Store your rules in a shared App Group container so the extension
can read the latest JSON written by the host app, then reload the blocker.
// Host app: write custom rules to shared container
func saveRules(_ rules: [[String: Any]]) throws {
let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.MyApp")!
let url = container.appendingPathComponent("blockerList.json")
let data = try JSONSerialization.data(withJSONObject: rules)
try data.write(to: url, options: .atomic)
}
// Extension: read from shared container instead of bundle
func beginRequest(with context: NSExtensionContext) {
let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.MyApp")!
let url = container.appendingPathComponent("blockerList.json")
// fall back to bundled defaults if missing
let resolvedURL = FileManager.default.fileExists(atPath: url.path)
? url
: Bundle.main.url(forResource: "blockerList", withExtension: "json")!
let item = NSExtensionItem()
item.attachments = [NSItemProvider(contentsOf: resolvedURL)!]
context.completeRequest(returningItems: [item])
}
CSS cosmetic filtering (hide elements)
Use "type": "css-display-none" with a "selector" key
in the action object to hide DOM elements without blocking the network request —
useful for cookie banners and sticky headers:
{
"trigger": { "url-filter": ".*" },
"action": {
"type": "css-display-none",
"selector": ".cookie-banner, #gdpr-overlay, .sticky-header"
}
}
Common pitfalls
-
iOS version floor: The
css-display-noneaction with multiple selectors and theunless-domaintrigger field were not fully reliable before iOS 16. On iOS 17+ both work correctly. Always test rule compilation viaWKContentRuleListStore.default().compileContentRuleListin a unit test to catch parse errors before shipping. -
50,000-rule hard limit: Safari enforces a maximum of 50,000
rules per extension. Exceeding this causes the entire rule list to be silently
rejected — your app will appear to block nothing. Merge rules with
if-domainarrays rather than creating one rule per domain. -
Accessibility & transparency: Surface the active rule
count and a deep-link to Settings in your UI. VoiceOver users need explicit
labels on the enabled/disabled status indicator; use
.accessibilityLabelon the combinedHStack, not individual icons, to avoid double-reading.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a content blocker extension in SwiftUI for iOS 17+. Use SFContentBlockerManager, NSExtensionRequestHandling, and NSItemProvider. Create ContentBlockerRequestHandler that reads blockerList.json from an App Group container with a bundle fallback. Build a ContentBlockerSettingsView with reload, status, and rule count. Make it accessible (VoiceOver labels on status indicator). Add a #Preview with realistic sample data.
Drop this prompt into Soarias during the Build phase after scaffolding your extension target — it generates the handler, JSON, shared container wiring, and SwiftUI settings screen in one pass.
Related
FAQ
Does this work on iOS 16?
The core SFContentBlockerManager API has been available since
iOS 9, so basic blocking rules work on iOS 16. However, some newer trigger
modifiers (unless-top-url, multi-selector CSS rules) behave
inconsistently below iOS 17. Soarias targets iOS 17+ throughout, so this
guide makes no effort to paper over iOS 16 edge cases.
How do I update rules without a full App Store release?
Fetch updated JSON from your server, write it to the shared App Group
container (see the dynamic rules variant above), then call
SFContentBlockerManager.reloadContentBlocker. Because the JSON
lives outside the app binary, you can ship new blocklists via a background
fetch without submitting a new build — only structural code changes require
an App Store review.
What is the UIKit / WKWebView equivalent?
For in-app WKWebViews you bypass the extension entirely and
use WKContentRuleListStore.default().compileContentRuleList(forIdentifier:encodedContentRuleList:completionHandler:)
to compile the same JSON, then attach the resulting
WKContentRuleList to your web view's
WKWebViewConfiguration.userContentController. This gives you
full in-process blocking without requiring the user to enable anything in
Safari settings.
Last reviewed: 2026-05-12 by the Soarias team.