```html SwiftUI: How to Implement Content Blocker (iOS 17+, 2026)

How to implement a content blocker in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: Content Blocker, SFContentBlockerManager, NSExtensionRequestHandling Updated: May 12, 2026
TL;DR

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

  1. blockerList.json — Each element in the array is a rule with a trigger (URL pattern, resource type, domain filters) and an action (block, block-cookies, css-display-none, ignore-previous-rules). Safari compiles these into a binary format once; recompilation only happens when you call reloadContentBlocker.
  2. ContentBlockerRequestHandler.beginRequest(with:) — Safari calls into your extension at compile time. You must return exactly one NSExtensionItem whose single attachment is an NSItemProvider wrapping the JSON file URL. Any error causes Safari to use the previously compiled rule set (or no rules on first load).
  3. 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 CFBundleIdentifier in its Info.plist.
  4. SFContentBlockerManager.getStateOfContentBlocker(withIdentifier:) — Returns an SFContentBlockerState whose isEnabled property 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.
  5. SwiftUI .task modifier — 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

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.

```