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:
- Native shell: Handles app lifecycle, permissions, push notifications, and receives deep links.
- WebView: Renders the UI using HTML/CSS/JavaScript.
- 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()
}
}
}
Forwarding Deep Links
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
}
}
}
}
Forwarding Deep Links
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) }
}
}
Web Layer: Receiving Deep Links
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.