Skip to content
Tolinku
Tolinku
Sign In Start Free
Engineering · · 6 min read

Deep Linking in WebView Bridges

By Tolinku Staff
|
Tolinku cross platform dashboard screenshot for engineering blog posts

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.

For WKWebView-specific behavior, see handling Universal Links in WKWebView. For Android WebView details, see Android WebView and App Links.

WebView Bridge Architecture

A WebView bridge app typically has three components:

  1. Native shell: Handles app lifecycle, permissions, push notifications, and receives deep links.
  2. WebView: Renders the UI using HTML/CSS/JavaScript.
  3. Bridge: A communication channel between native and web (JavaScript interface injection, message handlers, or postMessage).

Deep links arrive at the native shell. The bridge must forward them to the web layer, which then handles routing.

OS Deep Link → Native Shell → Bridge → Web Layer → Router → Screen

iOS: WKWebView Bridge

Setting Up the Bridge

WKWebView uses WKScriptMessageHandler for web-to-native communication and evaluateJavaScript for native-to-web.

import WebKit

class WebViewBridgeController: UIViewController, WKScriptMessageHandler {
    var webView: WKWebView!
    private var pendingDeepLink: URL?

    override func viewDidLoad() {
        super.viewDidLoad()

        let config = WKWebViewConfiguration()
        let contentController = WKUserContentController()

        // Register bridge handlers
        contentController.add(self, name: "nativeBridge")
        config.userContentController = contentController

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

        // Load web app
        let url = URL(string: "https://yourdomain.com/app")!
        webView.load(URLRequest(url: url))
    }

    // Receive messages from JavaScript
    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage
    ) {
        guard let body = message.body as? [String: Any] else { return }

        if let type = body["type"] as? String, type == "ready" {
            // Web layer is ready to receive deep links
            flushPendingDeepLink()
        }
    }
}
extension WebViewBridgeController {
    func handleDeepLink(_ url: URL) {
        if webView.isLoading {
            // WebView not ready, queue the URL
            pendingDeepLink = url
        } else {
            sendDeepLinkToWeb(url)
        }
    }

    private func flushPendingDeepLink() {
        if let url = pendingDeepLink {
            sendDeepLinkToWeb(url)
            pendingDeepLink = nil
        }
    }

    private func sendDeepLinkToWeb(_ url: URL) {
        let escaped = url.absoluteString
            .replacingOccurrences(of: "\\", with: "\\\\")
            .replacingOccurrences(of: "'", with: "\\'")

        webView.evaluateJavaScript(
            "window.__handleDeepLink && window.__handleDeepLink('\(escaped)')"
        ) { _, error in
            if let error = error {
                print("Deep link bridge error: \(error)")
            }
        }
    }
}

WKWebView Navigation Interception

Universal Links behave differently inside WKWebView. 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.

Intercept these navigations to handle them as deep links:

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

        // Check if this is an internal deep link
        if url.host == "yourdomain.com" && isDeepLinkPath(url.path) {
            // Route via the bridge instead of WebView navigation
            sendDeepLinkToWeb(url)
            decisionHandler(.cancel)
            return
        }

        // External links: open in Safari
        if url.host != "yourdomain.com" {
            UIApplication.shared.open(url)
            decisionHandler(.cancel)
            return
        }

        decisionHandler(.allow)
    }

    private func isDeepLinkPath(_ path: String) -> Bool {
        let deepLinkPrefixes = ["/products/", "/offers/", "/referral/", "/checkout/"]
        return deepLinkPrefixes.contains { path.hasPrefix($0) }
    }
}

Android: WebView Bridge

Setting Up the Bridge

Android WebView uses @JavascriptInterface for web-to-native communication and evaluateJavascript for native-to-web.

class WebViewBridgeActivity : AppCompatActivity() {
    private lateinit var webView: WebView
    private var pendingDeepLink: Uri? = null
    private var webReady = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_webview)

        webView = findViewById(R.id.webView)
        webView.settings.javaScriptEnabled = true
        webView.settings.domStorageEnabled = true

        // Add JavaScript bridge
        webView.addJavascriptInterface(NativeBridge(), "NativeBridge")

        // Set up navigation interception
        webView.webViewClient = DeepLinkWebViewClient()

        // Load web app
        webView.loadUrl("https://yourdomain.com/app")

        // Handle deep link from launch Intent
        intent?.data?.let { handleDeepLink(it) }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        intent.data?.let { handleDeepLink(it) }
    }

    inner class NativeBridge {
        @JavascriptInterface
        fun onReady() {
            webReady = true
            pendingDeepLink?.let { uri ->
                runOnUiThread { sendDeepLinkToWeb(uri) }
                pendingDeepLink = null
            }
        }
    }
}
fun handleDeepLink(uri: Uri) {
    if (webReady) {
        sendDeepLinkToWeb(uri)
    } else {
        pendingDeepLink = uri
    }
}

private fun sendDeepLinkToWeb(uri: Uri) {
    val escaped = uri.toString()
        .replace("\\", "\\\\")
        .replace("'", "\\'")

    webView.evaluateJavascript(
        "window.__handleDeepLink && window.__handleDeepLink('$escaped')"
    ) { result ->
        if (result == "null") {
            Log.w("DeepLink", "Web handler not found for: $uri")
        }
    }
}

Android WebView Navigation Interception

Similar to iOS, Android WebView needs to intercept links that should be handled as deep links rather than page navigations:

inner class DeepLinkWebViewClient : WebViewClient() {
    override fun shouldOverrideUrlLoading(
        view: WebView,
        request: WebResourceRequest
    ): Boolean {
        val url = request.url

        // Internal deep links: route via bridge
        if (url.host == "yourdomain.com" && isDeepLinkPath(url.path ?: "")) {
            sendDeepLinkToWeb(url)
            return true
        }

        // External links: open in browser
        if (url.host != "yourdomain.com") {
            startActivity(Intent(Intent.ACTION_VIEW, url))
            return true
        }

        // Regular internal navigation: let WebView handle it
        return false
    }

    private fun isDeepLinkPath(path: String): Boolean {
        val prefixes = listOf("/products/", "/offers/", "/referral/", "/checkout/")
        return prefixes.any { path.startsWith(it) }
    }
}

The web layer needs a global handler that the native bridge calls:

// deep-link-bridge.ts
type DeepLinkListener = (path: string, params: Record<string, string>) => void;

let listener: DeepLinkListener | null = null;
let pendingUrl: string | null = null;

// Called by native bridge
(window as any).__handleDeepLink = (urlString: string) => {
  if (listener) {
    const url = new URL(urlString);
    const params = Object.fromEntries(url.searchParams);
    listener(url.pathname, params);
  } else {
    // Router not ready yet, queue it
    pendingUrl = urlString;
  }
};

// Called by the app when the router is ready
export function onDeepLink(callback: DeepLinkListener) {
  listener = callback;

  // Flush any pending URL
  if (pendingUrl) {
    const url = new URL(pendingUrl);
    const params = Object.fromEntries(url.searchParams);
    callback(url.pathname, params);
    pendingUrl = null;
  }
}

// Signal to native that the web layer is ready
export function signalReady() {
  // iOS
  (window as any).webkit?.messageHandlers?.nativeBridge?.postMessage(
    { type: 'ready' }
  );

  // Android
  (window as any).NativeBridge?.onReady();
}

Integration with a Router

// app.ts
import { onDeepLink, signalReady } from './deep-link-bridge';
import { router } from './router';

// Set up the deep link handler
onDeepLink((path, params) => {
  router.navigate(path, { queryParams: params });
});

// Signal readiness after the router is initialized
signalReady();

Double-Firing Prevention

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.

// Deduplicate deep link handling
let lastHandledUrl = '';
let lastHandledTime = 0;

(window as any).__handleDeepLink = (urlString: string) => {
  const now = Date.now();

  // Ignore duplicate URLs within 1 second
  if (urlString === lastHandledUrl && now - lastHandledTime < 1000) {
    return;
  }

  lastHandledUrl = urlString;
  lastHandledTime = now;

  // Process the deep link
  if (listener) {
    const url = new URL(urlString);
    listener(url.pathname, Object.fromEntries(url.searchParams));
  }
};

Debugging Bridge Communication

When deep links are not working, isolate whether the problem is in the native layer, the bridge, or the web layer.

Step 1: Verify Native Receipt

Add logging to confirm the native layer receives the URL:

// iOS
func handleDeepLink(_ url: URL) {
    print("[DeepLink] Native received: \(url.absoluteString)")
    // ...
}
// Android
fun handleDeepLink(uri: Uri) {
    Log.d("DeepLink", "Native received: $uri")
    // ...
}

Step 2: Verify Bridge Delivery

Log the JavaScript evaluation:

// iOS
webView.evaluateJavaScript(js) { result, error in
    print("[DeepLink] Bridge result: \(String(describing: result)), error: \(String(describing: error))")
}

Step 3: Verify Web Receipt

(window as any).__handleDeepLink = (urlString: string) => {
  console.log('[DeepLink] Web received:', urlString);
  // ...
};

Step 4: Test Each Layer Independently

# Test native layer (iOS)
xcrun simctl openurl booted "https://yourdomain.com/products/123"

# Test native layer (Android)
adb shell am start -a android.intent.action.VIEW \
  -d "https://yourdomain.com/products/123" \
  -c android.intent.category.BROWSABLE

# Test web layer (browser console)
window.__handleDeepLink("https://yourdomain.com/products/123")

Security Considerations

WebView bridges introduce security surfaces that do not exist in pure native or pure web apps.

Validate URLs Before Forwarding

Never forward arbitrary URLs through the bridge without validation:

private func sendDeepLinkToWeb(_ url: URL) {
    // Only forward URLs from your domain
    guard url.host == "yourdomain.com" else {
        print("[DeepLink] Rejected non-matching host: \(url.host ?? "nil")")
        return
    }

    // Sanitize the URL string to prevent JavaScript injection
    guard let encoded = url.absoluteString.addingPercentEncoding(
        withAllowedCharacters: .urlQueryAllowed
    ) else { return }

    webView.evaluateJavaScript(
        "window.__handleDeepLink && window.__handleDeepLink(decodeURIComponent('\(encoded)'))"
    )
}

Restrict the Bridge Interface

Only expose the minimum necessary bridge methods:

// Good: specific, limited interface
inner class NativeBridge {
    @JavascriptInterface
    fun onReady() { /* ... */ }

    @JavascriptInterface
    fun logAnalytics(event: String) { /* ... */ }
}

// Bad: overly broad interface
// fun executeNativeCommand(command: String) { /* ... */ }

Tolinku for WebView Apps

Tolinku hosts AASA and assetlinks.json verification files, handles deferred deep linking for users who install the app after tapping a link, and provides click analytics across platforms.

For iOS-specific WebView behavior, see handling Universal Links in WKWebView. For Android, see Android WebView and App Links. For the full guide, see cross-platform deep linking guide for 2026.

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.