{"id":1882,"date":"2026-07-29T13:00:00","date_gmt":"2026-07-29T18:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=1882"},"modified":"2026-03-07T03:50:16","modified_gmt":"2026-03-07T08:50:16","slug":"webview-bridge-deep-links","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/webview-bridge-deep-links\/","title":{"rendered":"Deep Linking in WebView Bridges"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Apps that use a WebView bridge architecture load web content inside a native shell and use a JavaScript bridge to communicate between the two layers. Deep linking in these apps requires careful coordination: the native layer receives the URL, but the web layer owns the UI and navigation. This article covers the bridge patterns, pitfalls, and solutions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For WKWebView-specific behavior, see <a href=\"https:\/\/tolinku.com\/blog\/wkwebview-universal-links\/\">handling Universal Links in WKWebView<\/a>. For Android WebView details, see <a href=\"https:\/\/tolinku.com\/blog\/android-webview-app-links\/\">Android WebView and App Links<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">WebView Bridge Architecture<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A WebView bridge app typically has three components:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Native shell<\/strong>: Handles app lifecycle, permissions, push notifications, and receives deep links.<\/li>\n<li><strong>WebView<\/strong>: Renders the UI using HTML\/CSS\/JavaScript.<\/li>\n<li><strong>Bridge<\/strong>: A communication channel between native and web (JavaScript interface injection, message handlers, or postMessage).<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Deep links arrive at the native shell. The bridge must forward them to the web layer, which then handles routing.<\/p>\n\n\n\n<pre><code>OS Deep Link \u2192 Native Shell \u2192 Bridge \u2192 Web Layer \u2192 Router \u2192 Screen\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">iOS: WKWebView Bridge<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Setting Up the Bridge<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">WKWebView uses <code>WKScriptMessageHandler<\/code> for web-to-native communication and <code>evaluateJavaScript<\/code> for native-to-web.<\/p>\n\n\n\n<pre><code class=\"language-swift\">import WebKit\n\nclass WebViewBridgeController: UIViewController, WKScriptMessageHandler {\n    var webView: WKWebView!\n    private var pendingDeepLink: URL?\n\n    override func viewDidLoad() {\n        super.viewDidLoad()\n\n        let config = WKWebViewConfiguration()\n        let contentController = WKUserContentController()\n\n        \/\/ Register bridge handlers\n        contentController.add(self, name: &quot;nativeBridge&quot;)\n        config.userContentController = contentController\n\n        webView = WKWebView(frame: view.bounds, configuration: config)\n        webView.navigationDelegate = self\n        view.addSubview(webView)\n\n        \/\/ Load web app\n        let url = URL(string: &quot;https:\/\/yourdomain.com\/app&quot;)!\n        webView.load(URLRequest(url: url))\n    }\n\n    \/\/ Receive messages from JavaScript\n    func userContentController(\n        _ userContentController: WKUserContentController,\n        didReceive message: WKScriptMessage\n    ) {\n        guard let body = message.body as? [String: Any] else { return }\n\n        if let type = body[&quot;type&quot;] as? String, type == &quot;ready&quot; {\n            \/\/ Web layer is ready to receive deep links\n            flushPendingDeepLink()\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Forwarding Deep Links<\/h3>\n\n\n\n<pre><code class=\"language-swift\">extension WebViewBridgeController {\n    func handleDeepLink(_ url: URL) {\n        if webView.isLoading {\n            \/\/ WebView not ready, queue the URL\n            pendingDeepLink = url\n        } else {\n            sendDeepLinkToWeb(url)\n        }\n    }\n\n    private func flushPendingDeepLink() {\n        if let url = pendingDeepLink {\n            sendDeepLinkToWeb(url)\n            pendingDeepLink = nil\n        }\n    }\n\n    private func sendDeepLinkToWeb(_ url: URL) {\n        let escaped = url.absoluteString\n            .replacingOccurrences(of: &quot;\\\\&quot;, with: &quot;\\\\\\\\&quot;)\n            .replacingOccurrences(of: &quot;&#39;&quot;, with: &quot;\\\\&#39;&quot;)\n\n        webView.evaluateJavaScript(\n            &quot;window.__handleDeepLink &amp;&amp; window.__handleDeepLink(&#39;\\(escaped)&#39;)&quot;\n        ) { _, error in\n            if let error = error {\n                print(&quot;Deep link bridge error: \\(error)&quot;)\n            }\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">WKWebView Navigation Interception<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/tolinku.com\/blog\/wkwebview-universal-links\/\">Universal Links behave differently inside WKWebView<\/a>. When a user taps a link inside the WebView that matches your Associated Domains, WKWebView navigates to it as a regular web page rather than triggering the Universal Link handler.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Intercept these navigations to handle them as deep links:<\/p>\n\n\n\n<pre><code class=\"language-swift\">extension WebViewBridgeController: WKNavigationDelegate {\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 is an internal deep link\n        if url.host == &quot;yourdomain.com&quot; &amp;&amp; isDeepLinkPath(url.path) {\n            \/\/ Route via the bridge instead of WebView navigation\n            sendDeepLinkToWeb(url)\n            decisionHandler(.cancel)\n            return\n        }\n\n        \/\/ External links: open in Safari\n        if url.host != &quot;yourdomain.com&quot; {\n            UIApplication.shared.open(url)\n            decisionHandler(.cancel)\n            return\n        }\n\n        decisionHandler(.allow)\n    }\n\n    private func isDeepLinkPath(_ path: String) -&gt; Bool {\n        let deepLinkPrefixes = [&quot;\/products\/&quot;, &quot;\/offers\/&quot;, &quot;\/referral\/&quot;, &quot;\/checkout\/&quot;]\n        return deepLinkPrefixes.contains { path.hasPrefix($0) }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Android: WebView Bridge<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Setting Up the Bridge<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Android WebView uses <code>@JavascriptInterface<\/code> for web-to-native communication and <code>evaluateJavascript<\/code> for native-to-web.<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">class WebViewBridgeActivity : AppCompatActivity() {\n    private lateinit var webView: WebView\n    private var pendingDeepLink: Uri? = null\n    private var webReady = false\n\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n        setContentView(R.layout.activity_webview)\n\n        webView = findViewById(R.id.webView)\n        webView.settings.javaScriptEnabled = true\n        webView.settings.domStorageEnabled = true\n\n        \/\/ Add JavaScript bridge\n        webView.addJavascriptInterface(NativeBridge(), &quot;NativeBridge&quot;)\n\n        \/\/ Set up navigation interception\n        webView.webViewClient = DeepLinkWebViewClient()\n\n        \/\/ Load web app\n        webView.loadUrl(&quot;https:\/\/yourdomain.com\/app&quot;)\n\n        \/\/ Handle deep link from launch Intent\n        intent?.data?.let { handleDeepLink(it) }\n    }\n\n    override fun onNewIntent(intent: Intent) {\n        super.onNewIntent(intent)\n        intent.data?.let { handleDeepLink(it) }\n    }\n\n    inner class NativeBridge {\n        @JavascriptInterface\n        fun onReady() {\n            webReady = true\n            pendingDeepLink?.let { uri -&gt;\n                runOnUiThread { sendDeepLinkToWeb(uri) }\n                pendingDeepLink = null\n            }\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Forwarding Deep Links<\/h3>\n\n\n\n<pre><code class=\"language-kotlin\">fun handleDeepLink(uri: Uri) {\n    if (webReady) {\n        sendDeepLinkToWeb(uri)\n    } else {\n        pendingDeepLink = uri\n    }\n}\n\nprivate fun sendDeepLinkToWeb(uri: Uri) {\n    val escaped = uri.toString()\n        .replace(&quot;\\\\&quot;, &quot;\\\\\\\\&quot;)\n        .replace(&quot;&#39;&quot;, &quot;\\\\&#39;&quot;)\n\n    webView.evaluateJavascript(\n        &quot;window.__handleDeepLink &amp;&amp; window.__handleDeepLink(&#39;$escaped&#39;)&quot;\n    ) { result -&gt;\n        if (result == &quot;null&quot;) {\n            Log.w(&quot;DeepLink&quot;, &quot;Web handler not found for: $uri&quot;)\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Android WebView Navigation Interception<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Similar to iOS, <a href=\"https:\/\/tolinku.com\/blog\/android-webview-app-links\/\">Android WebView needs to intercept links<\/a> that should be handled as deep links rather than page navigations:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">inner class DeepLinkWebViewClient : WebViewClient() {\n    override fun shouldOverrideUrlLoading(\n        view: WebView,\n        request: WebResourceRequest\n    ): Boolean {\n        val url = request.url\n\n        \/\/ Internal deep links: route via bridge\n        if (url.host == &quot;yourdomain.com&quot; &amp;&amp; isDeepLinkPath(url.path ?: &quot;&quot;)) {\n            sendDeepLinkToWeb(url)\n            return true\n        }\n\n        \/\/ External links: open in browser\n        if (url.host != &quot;yourdomain.com&quot;) {\n            startActivity(Intent(Intent.ACTION_VIEW, url))\n            return true\n        }\n\n        \/\/ Regular internal navigation: let WebView handle it\n        return false\n    }\n\n    private fun isDeepLinkPath(path: String): Boolean {\n        val prefixes = listOf(&quot;\/products\/&quot;, &quot;\/offers\/&quot;, &quot;\/referral\/&quot;, &quot;\/checkout\/&quot;)\n        return prefixes.any { path.startsWith(it) }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Web Layer: Receiving Deep Links<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The web layer needs a global handler that the native bridge calls:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ deep-link-bridge.ts\ntype DeepLinkListener = (path: string, params: Record&lt;string, string&gt;) =&gt; void;\n\nlet listener: DeepLinkListener | null = null;\nlet pendingUrl: string | null = null;\n\n\/\/ Called by native bridge\n(window as any).__handleDeepLink = (urlString: string) =&gt; {\n  if (listener) {\n    const url = new URL(urlString);\n    const params = Object.fromEntries(url.searchParams);\n    listener(url.pathname, params);\n  } else {\n    \/\/ Router not ready yet, queue it\n    pendingUrl = urlString;\n  }\n};\n\n\/\/ Called by the app when the router is ready\nexport function onDeepLink(callback: DeepLinkListener) {\n  listener = callback;\n\n  \/\/ Flush any pending URL\n  if (pendingUrl) {\n    const url = new URL(pendingUrl);\n    const params = Object.fromEntries(url.searchParams);\n    callback(url.pathname, params);\n    pendingUrl = null;\n  }\n}\n\n\/\/ Signal to native that the web layer is ready\nexport function signalReady() {\n  \/\/ iOS\n  (window as any).webkit?.messageHandlers?.nativeBridge?.postMessage(\n    { type: &#39;ready&#39; }\n  );\n\n  \/\/ Android\n  (window as any).NativeBridge?.onReady();\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Integration with a Router<\/h3>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ app.ts\nimport { onDeepLink, signalReady } from &#39;.\/deep-link-bridge&#39;;\nimport { router } from &#39;.\/router&#39;;\n\n\/\/ Set up the deep link handler\nonDeepLink((path, params) =&gt; {\n  router.navigate(path, { queryParams: params });\n});\n\n\/\/ Signal readiness after the router is initialized\nsignalReady();\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Double-Firing Prevention<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A common bug in WebView bridge apps: deep links fire twice. This happens when both the navigation delegate\/WebViewClient and the bridge handler process the same URL.<\/p>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ Deduplicate deep link handling\nlet lastHandledUrl = &#39;&#39;;\nlet lastHandledTime = 0;\n\n(window as any).__handleDeepLink = (urlString: string) =&gt; {\n  const now = Date.now();\n\n  \/\/ Ignore duplicate URLs within 1 second\n  if (urlString === lastHandledUrl &amp;&amp; now - lastHandledTime &lt; 1000) {\n    return;\n  }\n\n  lastHandledUrl = urlString;\n  lastHandledTime = now;\n\n  \/\/ Process the deep link\n  if (listener) {\n    const url = new URL(urlString);\n    listener(url.pathname, Object.fromEntries(url.searchParams));\n  }\n};\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Debugging Bridge Communication<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When deep links are not working, isolate whether the problem is in the native layer, the bridge, or the web layer.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1: Verify Native Receipt<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Add logging to confirm the native layer receives the URL:<\/p>\n\n\n\n<pre><code class=\"language-swift\">\/\/ iOS\nfunc handleDeepLink(_ url: URL) {\n    print(&quot;[DeepLink] Native received: \\(url.absoluteString)&quot;)\n    \/\/ ...\n}\n<\/code><\/pre>\n\n\n\n<pre><code class=\"language-kotlin\">\/\/ Android\nfun handleDeepLink(uri: Uri) {\n    Log.d(&quot;DeepLink&quot;, &quot;Native received: $uri&quot;)\n    \/\/ ...\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2: Verify Bridge Delivery<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Log the JavaScript evaluation:<\/p>\n\n\n\n<pre><code class=\"language-swift\">\/\/ iOS\nwebView.evaluateJavaScript(js) { result, error in\n    print(&quot;[DeepLink] Bridge result: \\(String(describing: result)), error: \\(String(describing: error))&quot;)\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3: Verify Web Receipt<\/h3>\n\n\n\n<pre><code class=\"language-typescript\">(window as any).__handleDeepLink = (urlString: string) =&gt; {\n  console.log(&#39;[DeepLink] Web received:&#39;, urlString);\n  \/\/ ...\n};\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4: Test Each Layer Independently<\/h3>\n\n\n\n<pre><code class=\"language-bash\"># Test native layer (iOS)\nxcrun simctl openurl booted &quot;https:\/\/yourdomain.com\/products\/123&quot;\n\n# Test native layer (Android)\nadb shell am start -a android.intent.action.VIEW \\\n  -d &quot;https:\/\/yourdomain.com\/products\/123&quot; \\\n  -c android.intent.category.BROWSABLE\n\n# Test web layer (browser console)\nwindow.__handleDeepLink(&quot;https:\/\/yourdomain.com\/products\/123&quot;)\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Security Considerations<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">WebView bridges introduce security surfaces that do not exist in pure native or pure web apps.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Validate URLs Before Forwarding<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Never forward arbitrary URLs through the bridge without validation:<\/p>\n\n\n\n<pre><code class=\"language-swift\">private func sendDeepLinkToWeb(_ url: URL) {\n    \/\/ Only forward URLs from your domain\n    guard url.host == &quot;yourdomain.com&quot; else {\n        print(&quot;[DeepLink] Rejected non-matching host: \\(url.host ?? &quot;nil&quot;)&quot;)\n        return\n    }\n\n    \/\/ Sanitize the URL string to prevent JavaScript injection\n    guard let encoded = url.absoluteString.addingPercentEncoding(\n        withAllowedCharacters: .urlQueryAllowed\n    ) else { return }\n\n    webView.evaluateJavaScript(\n        &quot;window.__handleDeepLink &amp;&amp; window.__handleDeepLink(decodeURIComponent(&#39;\\(encoded)&#39;))&quot;\n    )\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Restrict the Bridge Interface<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Only expose the minimum necessary bridge methods:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">\/\/ Good: specific, limited interface\ninner class NativeBridge {\n    @JavascriptInterface\n    fun onReady() { \/* ... *\/ }\n\n    @JavascriptInterface\n    fun logAnalytics(event: String) { \/* ... *\/ }\n}\n\n\/\/ Bad: overly broad interface\n\/\/ fun executeNativeCommand(command: String) { \/* ... *\/ }\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Tolinku for WebView Apps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/tolinku.com\/features\/deep-linking\">Tolinku<\/a> hosts AASA and assetlinks.json verification files, handles <a href=\"https:\/\/tolinku.com\/docs\/concepts\/deep-linking\/\">deferred deep linking<\/a> for users who install the app after tapping a link, and provides click analytics across platforms.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For iOS-specific WebView behavior, see <a href=\"https:\/\/tolinku.com\/blog\/wkwebview-universal-links\/\">handling Universal Links in WKWebView<\/a>. For Android, see <a href=\"https:\/\/tolinku.com\/blog\/android-webview-app-links\/\">Android WebView and App Links<\/a>. For the full guide, see <a href=\"https:\/\/tolinku.com\/blog\/cross-platform-deep-linking-guide\/\">cross-platform deep linking guide for 2026<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Handle deep links in apps using WebView bridges. Manage communication between native and web layers for consistent deep link behavior.<\/p>\n","protected":false},"author":2,"featured_media":1881,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Deep Linking in WebView Bridges","rank_math_description":"Handle deep links in apps using WebView bridges. Manage communication between native and web layers for consistent deep link behavior.","rank_math_focus_keyword":"WebView bridge deep 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-webview-bridge-deep-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-webview-bridge-deep-links.png","footnotes":""},"categories":[15],"tags":[570,23,156,20,571,69,22,41,325,569],"class_list":["post-1882","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-engineering","tag-android-webview","tag-app-links","tag-cross-platform","tag-deep-linking","tag-javascript-bridge","tag-mobile-development","tag-universal-links","tag-web-to-app","tag-webview","tag-wkwebview"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1882","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=1882"}],"version-history":[{"count":1,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1882\/revisions"}],"predecessor-version":[{"id":1883,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1882\/revisions\/1883"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/1881"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=1882"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=1882"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=1882"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}