{"id":2041,"date":"2026-08-15T17:00:00","date_gmt":"2026-08-15T22:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=2041"},"modified":"2026-03-07T03:50:28","modified_gmt":"2026-03-07T08:50:28","slug":"wkwebview-universal-links","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/wkwebview-universal-links\/","title":{"rendered":"Handling Universal Links in WKWebView"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">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&#39;s, and if you do not configure it correctly, Universal Links tapped inside your WebView will behave in unexpected ways.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For Universal Links fundamentals, see <a href=\"https:\/\/tolinku.com\/blog\/universal-links-everything-you-need-to-know\/\">universal links: everything you need to know<\/a>. For Safari-specific behaviors, see <a href=\"https:\/\/tolinku.com\/blog\/universal-links-in-safari\/\">universal links in Safari: behavior and edge cases<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why WKWebView Handles Universal Links Differently<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In Safari, tapping a Universal Link triggers the system&#39;s URL dispatcher. iOS checks the Associated Domains entitlement, finds the matching app, and opens it. This happens at the OS level.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">WKWebView is a UI component that renders web content inside your app&#39;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).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is a documented behavior difference. Apple&#39;s <a href=\"https:\/\/developer.apple.com\/documentation\/webkit\/wkwebview\" rel=\"nofollow noopener\" target=\"_blank\">WKWebView documentation<\/a> notes that WKWebView does not participate in the Universal Links dispatch chain automatically.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The WKNavigationDelegate<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The right place to intercept Universal Links in WKWebView is <code>WKNavigationDelegate<\/code>. Specifically, you implement <code>webView(_:decidePolicyFor:decisionHandler:)<\/code> to evaluate every navigation action before it happens.<\/p>\n\n\n\n<pre><code class=\"language-swift\">import WebKit\n\nclass WebViewController: UIViewController, WKNavigationDelegate {\n\n    var webView: WKWebView!\n\n    override func viewDidLoad() {\n        super.viewDidLoad()\n\n        let config = WKWebViewConfiguration()\n        webView = WKWebView(frame: view.bounds, configuration: config)\n        webView.navigationDelegate = self\n        view.addSubview(webView)\n\n        if let url = URL(string: &quot;https:\/\/yourdomain.com&quot;) {\n            webView.load(URLRequest(url: url))\n        }\n    }\n\n    func webView(\n        _ webView: WKWebView,\n        decidePolicyFor navigationAction: WKNavigationAction,\n        decisionHandler: @escaping (WKNavigationActionPolicy) -&gt; Void\n    ) {\n        guard let url = navigationAction.request.url else {\n            decisionHandler(.allow)\n            return\n        }\n\n        \/\/ Check if this URL is a Universal Link for another app\n        if isUniversalLink(url) {\n            UIApplication.shared.open(url, options: [:]) { success in\n                if !success {\n                    \/\/ App not installed or link not handled, fall back to web\n                }\n            }\n            decisionHandler(.cancel)\n            return\n        }\n\n        decisionHandler(.allow)\n    }\n\n    private func isUniversalLink(_ url: URL) -&gt; Bool {\n        let universalLinkDomains = [&quot;app.yourdomain.com&quot;, &quot;yourdomain.com&quot;]\n        guard let host = url.host else { return false }\n        return universalLinkDomains.contains(host)\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The key decision is when to call <code>decisionHandler(.cancel)<\/code> versus <code>decisionHandler(.allow)<\/code>. Calling <code>.cancel<\/code> stops the WebView from loading the URL. Calling <code>.allow<\/code> lets the WebView proceed normally.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Your Own App&#39;s Universal Links Inside a WebView<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If your WebView is loading your own domain and you want Universal Links to open specific native screens, intercept the navigation and route internally:<\/p>\n\n\n\n<pre><code class=\"language-swift\">func webView(\n    _ webView: WKWebView,\n    decidePolicyFor navigationAction: WKNavigationAction,\n    decisionHandler: @escaping (WKNavigationActionPolicy) -&gt; Void\n) {\n    guard let url = navigationAction.request.url else {\n        decisionHandler(.allow)\n        return\n    }\n\n    \/\/ Route internal Universal Links to native screens\n    if url.host == &quot;yourdomain.com&quot;, let path = routeToNativeScreen(url) {\n        navigateToScreen(path)\n        decisionHandler(.cancel)\n        return\n    }\n\n    decisionHandler(.allow)\n}\n\nprivate func routeToNativeScreen(_ url: URL) -&gt; String? {\n    let path = url.path\n    switch path {\n    case let p where p.hasPrefix(&quot;\/product\/&quot;):\n        return &quot;product&quot;\n    case let p where p.hasPrefix(&quot;\/checkout&quot;):\n        return &quot;checkout&quot;\n    default:\n        return nil\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This pattern keeps the user in a native flow rather than loading a web page for content that has a native equivalent.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The <code>allowsLinkPreview<\/code> Property<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">WKWebView has an <code>allowsLinkPreview<\/code> property that enables iOS&#39;s peek-and-pop or link preview behavior when users long-press a link. This uses a separate code path than <code>decidePolicyFor<\/code> and can sometimes bypass your navigation policy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If link previews create issues with your navigation policy, disable them:<\/p>\n\n\n\n<pre><code class=\"language-swift\">webView.allowsLinkPreview = false\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Apple&#39;s documentation on this property is at <a href=\"https:\/\/developer.apple.com\/documentation\/webkit\/wkwebview\/1415804-allowslinkpreview\" rel=\"nofollow noopener\" target=\"_blank\">WKWebView.allowsLinkPreview<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Redirects<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Universal Links often involve redirects. A short link might redirect to your Universal Link domain. The <code>decidePolicyFor<\/code> delegate method is called for each navigation step, including redirects, so your policy check applies throughout the redirect chain.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">However, server-side redirects (HTTP 301\/302) arrive as a different navigation action type. Use <code>WKNavigationDelegate<\/code>&#39;s response-based delegate method to inspect the response after the server responds:<\/p>\n\n\n\n<pre><code class=\"language-swift\">func webView(\n    _ webView: WKWebView,\n    decidePolicyFor navigationResponse: WKNavigationResponse,\n    decisionHandler: @escaping (WKNavigationResponsePolicy) -&gt; Void\n) {\n    guard let httpResponse = navigationResponse.response as? HTTPURLResponse else {\n        decisionHandler(.allow)\n        return\n    }\n\n    \/\/ Inspect the final URL after any redirects\n    let finalURL = httpResponse.url\n    decisionHandler(.allow)\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Common Pitfalls<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Not setting the navigation delegate.<\/strong> If <code>webView.navigationDelegate<\/code> is not set to your controller, <code>decidePolicyFor<\/code> is never called and all navigation proceeds unchecked.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Forgetting to call <code>decisionHandler<\/code>.<\/strong> Every path through <code>decidePolicyFor<\/code> must call the <code>decisionHandler<\/code> closure exactly once. Failing to call it causes an assertion failure, and calling it twice causes undefined behavior. Use <code>guard<\/code> statements that always reach a <code>decisionHandler<\/code> call.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checking the wrong navigation action type.<\/strong> <code>WKNavigationAction<\/code> has a <code>navigationType<\/code> property (<code>.linkActivated<\/code>, <code>.formSubmitted<\/code>, <code>.reload<\/code>, <code>.backForward<\/code>, etc.). You may want to apply your Universal Link check only to <code>.linkActivated<\/code> navigations:<\/p>\n\n\n\n<pre><code class=\"language-swift\">guard navigationAction.navigationType == .linkActivated else {\n    decisionHandler(.allow)\n    return\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Not accounting for JavaScript-initiated navigation.<\/strong> Single-page applications often use JavaScript to change the URL (via <code>history.pushState<\/code> or <code>location.href<\/code>). These navigations may arrive as <code>.other<\/code> navigation type. If your SPA uses Universal Link paths, your policy check needs to handle those too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Missing back-navigation handling.<\/strong> 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&#39;s flow.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">iOS 16.4+ Navigation Delegate Changes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Apple introduced <code>webView(_:decidePolicyFor:preferences:decisionHandler:)<\/code> (with a <code>WKWebpagePreferences<\/code> 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 <a href=\"https:\/\/developer.apple.com\/documentation\/webkit\/wknavigationdelegate\" rel=\"nofollow noopener\" target=\"_blank\">WKNavigationDelegate reference<\/a> for the current recommended signature for your deployment target.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Tolinku for WKWebView Deep Links<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/tolinku.com\/features\/deep-linking\">Tolinku<\/a> manages your AASA file and route configuration. When a user taps a Tolinku-managed deep link inside your WKWebView, your <code>decidePolicyFor<\/code> implementation sees the URL on your Tolinku domain (subdomain or custom domain). You can check that domain in your <code>isUniversalLink<\/code> function and route accordingly. See the <a href=\"https:\/\/tolinku.com\/docs\/developer\/universal-links\/\">Universal Links developer guide<\/a> for how to configure your domain for deep linking.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For SFSafariViewController as an alternative, see <a href=\"https:\/\/tolinku.com\/blog\/sfsafariviewcontroller-deep-links\/\">SFSafariViewController and deep links: best practices<\/a>. For the complete Universal Links guide, see <a href=\"https:\/\/tolinku.com\/blog\/universal-links-everything-you-need-to-know\/\">universal links: everything you need to know<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Configure WKWebView to handle Universal Links correctly. Learn delegation, navigation policies, and common WebView deep linking issues.<\/p>\n","protected":false},"author":2,"featured_media":2040,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Handling Universal Links in WKWebView: A Developer Guide","rank_math_description":"Configure WKWebView to handle Universal Links correctly. Learn delegation, navigation policies, and common WebView deep linking issues.","rank_math_focus_keyword":"WKWebView universal links","rank_math_canonical_url":"","rank_math_facebook_title":"","rank_math_facebook_description":"","rank_math_facebook_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-wkwebview-universal-links.png","rank_math_facebook_image_id":"","rank_math_twitter_title":"","rank_math_twitter_description":"","rank_math_twitter_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-wkwebview-universal-links.png","footnotes":""},"categories":[11],"tags":[648,20,315,24,650,31,22,649,325,569],"class_list":["post-2041","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-deep-linking","tag-app-development","tag-deep-linking","tag-in-app-browser","tag-ios","tag-navigation-delegate","tag-swift","tag-universal-links","tag-webkit","tag-webview","tag-wkwebview"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/2041","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/comments?post=2041"}],"version-history":[{"count":1,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/2041\/revisions"}],"predecessor-version":[{"id":2042,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/2041\/revisions\/2042"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/2040"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=2041"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=2041"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=2041"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}