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.
Why WKWebView Handles Universal Links Differently
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.
Handling Your Own App's Universal Links Inside a WebView
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 for WKWebView Deep Links
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.