Skip to content
Tolinku
Tolinku
Sign In Start Free
Deep Linking · · 5 min read

Handling Universal Links in WKWebView

By Tolinku Staff
|
Tolinku deep linking fundamentals dashboard screenshot for deep linking blog posts

WKWebView is the foundation for in-app browsing in iOS apps. It is fast, modern, and supports the same JavaScript engine as Safari. But its relationship with Universal Links is different from Safari's, and if you do not configure it correctly, Universal Links tapped inside your WebView will behave in unexpected ways.

This article explains how WKWebView processes navigation, how to intercept Universal Links before they escape the WebView context, and the common pitfalls that cause deep linking to fail inside embedded web views.

For Universal Links fundamentals, see universal links: everything you need to know. For Safari-specific behaviors, see universal links in Safari: behavior and edge cases.

In Safari, tapping a Universal Link triggers the system's URL dispatcher. iOS checks the Associated Domains entitlement, finds the matching app, and opens it. This happens at the OS level.

WKWebView is a UI component that renders web content inside your app's process. It does not automatically defer URL navigation to the iOS dispatcher the way Safari does. When a user taps a link inside a WKWebView, the WebView intercepts that navigation internally. Without explicit configuration, Universal Links tapped inside a WKWebView will simply load as regular web pages inside the WebView, not open the app (or another app).

This is a documented behavior difference. Apple's WKWebView documentation notes that WKWebView does not participate in the Universal Links dispatch chain automatically.

The WKNavigationDelegate

The right place to intercept Universal Links in WKWebView is WKNavigationDelegate. Specifically, you implement webView(_:decidePolicyFor:decisionHandler:) to evaluate every navigation action before it happens.

import WebKit

class WebViewController: UIViewController, WKNavigationDelegate {

    var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()

        let config = WKWebViewConfiguration()
        webView = WKWebView(frame: view.bounds, configuration: config)
        webView.navigationDelegate = self
        view.addSubview(webView)

        if let url = URL(string: "https://yourdomain.com") {
            webView.load(URLRequest(url: url))
        }
    }

    func webView(
        _ webView: WKWebView,
        decidePolicyFor navigationAction: WKNavigationAction,
        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
    ) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.allow)
            return
        }

        // Check if this URL is a Universal Link for another app
        if isUniversalLink(url) {
            UIApplication.shared.open(url, options: [:]) { success in
                if !success {
                    // App not installed or link not handled, fall back to web
                }
            }
            decisionHandler(.cancel)
            return
        }

        decisionHandler(.allow)
    }

    private func isUniversalLink(_ url: URL) -> Bool {
        let universalLinkDomains = ["app.yourdomain.com", "yourdomain.com"]
        guard let host = url.host else { return false }
        return universalLinkDomains.contains(host)
    }
}

The key decision is when to call decisionHandler(.cancel) versus decisionHandler(.allow). Calling .cancel stops the WebView from loading the URL. Calling .allow lets the WebView proceed normally.

If your WebView is loading your own domain and you want Universal Links to open specific native screens, intercept the navigation and route internally:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    guard let url = navigationAction.request.url else {
        decisionHandler(.allow)
        return
    }

    // Route internal Universal Links to native screens
    if url.host == "yourdomain.com", let path = routeToNativeScreen(url) {
        navigateToScreen(path)
        decisionHandler(.cancel)
        return
    }

    decisionHandler(.allow)
}

private func routeToNativeScreen(_ url: URL) -> String? {
    let path = url.path
    switch path {
    case let p where p.hasPrefix("/product/"):
        return "product"
    case let p where p.hasPrefix("/checkout"):
        return "checkout"
    default:
        return nil
    }
}

This pattern keeps the user in a native flow rather than loading a web page for content that has a native equivalent.

The allowsLinkPreview Property

WKWebView has an allowsLinkPreview property that enables iOS's peek-and-pop or link preview behavior when users long-press a link. This uses a separate code path than decidePolicyFor and can sometimes bypass your navigation policy.

If link previews create issues with your navigation policy, disable them:

webView.allowsLinkPreview = false

Apple's documentation on this property is at WKWebView.allowsLinkPreview.

Handling Redirects

Universal Links often involve redirects. A short link might redirect to your Universal Link domain. The decidePolicyFor delegate method is called for each navigation step, including redirects, so your policy check applies throughout the redirect chain.

However, server-side redirects (HTTP 301/302) arrive as a different navigation action type. Use WKNavigationDelegate's response-based delegate method to inspect the response after the server responds:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationResponse: WKNavigationResponse,
    decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
) {
    guard let httpResponse = navigationResponse.response as? HTTPURLResponse else {
        decisionHandler(.allow)
        return
    }

    // Inspect the final URL after any redirects
    let finalURL = httpResponse.url
    decisionHandler(.allow)
}

Common Pitfalls

Not setting the navigation delegate. If webView.navigationDelegate is not set to your controller, decidePolicyFor is never called and all navigation proceeds unchecked.

Forgetting to call decisionHandler. Every path through decidePolicyFor must call the decisionHandler closure exactly once. Failing to call it causes an assertion failure, and calling it twice causes undefined behavior. Use guard statements that always reach a decisionHandler call.

Checking the wrong navigation action type. WKNavigationAction has a navigationType property (.linkActivated, .formSubmitted, .reload, .backForward, etc.). You may want to apply your Universal Link check only to .linkActivated navigations:

guard navigationAction.navigationType == .linkActivated else {
    decisionHandler(.allow)
    return
}

Not accounting for JavaScript-initiated navigation. Single-page applications often use JavaScript to change the URL (via history.pushState or location.href). These navigations may arrive as .other navigation type. If your SPA uses Universal Link paths, your policy check needs to handle those too.

Missing back-navigation handling. If you cancel a navigation and open the native app, the WebView stays on the previous page. Make sure the user experience makes sense. You may want to dismiss the WebView, depending on your app's flow.

iOS 16.4+ Navigation Delegate Changes

Apple introduced webView(_:decidePolicyFor:preferences:decisionHandler:) (with a WKWebpagePreferences parameter) as an alternative to the older signature. If you implement both, only the newer version is called on iOS 16.4 and later. Check the WKNavigationDelegate reference for the current recommended signature for your deployment target.

Tolinku manages your AASA file and route configuration. When a user taps a Tolinku-managed deep link inside your WKWebView, your decidePolicyFor implementation sees the URL on your Tolinku domain (subdomain or custom domain). You can check that domain in your isUniversalLink function and route accordingly. See the Universal Links developer guide for how to configure your domain for deep linking.

For SFSafariViewController as an alternative, see SFSafariViewController and deep links: best practices. For the complete Universal Links guide, see universal links: everything you need to know.

Get deep linking tips in your inbox

One email per week. No spam.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.