Hybrid apps combine web technologies with native wrappers. Frameworks like Capacitor, Cordova, and custom WebView shells let teams ship cross-platform apps from a single codebase. Deep linking in these apps introduces specific challenges: the native layer receives the URL, but the web layer handles navigation. Bridging that gap cleanly is the core problem.
For Capacitor-specific setup, see Capacitor deep linking: setup for Ionic apps. For the cross-platform overview, see cross-platform deep linking guide for 2026.
How Deep Links Reach Hybrid Apps
When a user taps a Universal Link (iOS) or App Link (Android), the operating system opens the app and passes the URL to the native layer. In hybrid apps, this means:
- iOS calls
application(_:continue:restorationHandler:)on the AppDelegate. - Android delivers an Intent with the URL to the Activity.
- The native layer forwards the URL to the WebView or JavaScript bridge.
- The web layer parses the URL and navigates to the correct view.
Each step can fail independently. A misconfigured entitlement stops the link at step 1. A broken bridge stops it at step 3. A router mismatch stops it at step 4.
Challenge 1: Native-to-Web Handoff
The most common failure point is getting the URL from the native layer into the web layer reliably.
The Timing Problem
When the app launches from a deep link (cold start), the WebView may not be ready when the URL arrives. The native layer receives the URL immediately, but the JavaScript context needs time to initialize.
// Problem: web layer not ready during cold start
nativeBridge.onDeepLink((url) => {
// This fires before the router is initialized
router.navigate(url); // Error: router not ready
});
Solution: Queue and Replay
Buffer incoming URLs until the web layer signals readiness:
// iOS native side
class DeepLinkBridge {
private var pendingUrl: String?
private var webViewReady = false
func handleUniversalLink(_ url: URL) {
if webViewReady {
sendToWebView(url.absoluteString)
} else {
pendingUrl = url.absoluteString
}
}
func onWebViewReady() {
webViewReady = true
if let url = pendingUrl {
sendToWebView(url)
pendingUrl = nil
}
}
private func sendToWebView(_ url: String) {
webView.evaluateJavaScript(
"window.handleDeepLink('\(url)')"
)
}
}
On the web side:
// Web layer signals readiness
window.handleDeepLink = (url: string) => {
const parsed = new URL(url);
router.navigate(parsed.pathname + parsed.search);
};
// Signal to native that the web layer is ready
window.webkit?.messageHandlers?.bridge?.postMessage('ready');
Capacitor handles this automatically through its appUrlOpen event and getLaunchUrl() API. If you are using Capacitor, you do not need to build this yourself. See the Capacitor deep linking guide for details.
Challenge 2: Navigation State Mismatch
Hybrid apps often maintain navigation state in the web layer (using a JavaScript router) while the native layer controls the WebView container. Deep links can create conflicts between these two layers.
The Problem
Consider an app with a tab bar managed natively and page content managed by a web router. A deep link to /products/123 needs to:
- Switch to the correct native tab.
- Navigate the web router within that tab to the product page.
- Maintain the back stack so the user can navigate back.
If either layer gets out of sync, the user sees the wrong tab highlighted or gets stuck with no back button.
Solution: Coordinated Navigation
Define a contract between the native and web layers:
// Shared route mapping
interface DeepLinkRoute {
nativeTab: string;
webPath: string;
params: Record<string, string>;
}
function resolveRoute(url: URL): DeepLinkRoute | null {
const path = url.pathname;
const productMatch = path.match(/^\/products\/([^/]+)$/);
if (productMatch) {
return {
nativeTab: 'shop',
webPath: `/products/${productMatch[1]}`,
params: Object.fromEntries(url.searchParams)
};
}
const offerMatch = path.match(/^\/offers\/([^/]+)$/);
if (offerMatch) {
return {
nativeTab: 'deals',
webPath: `/offers/${offerMatch[1]}`,
params: Object.fromEntries(url.searchParams)
};
}
return null;
}
The native side switches tabs. The web side handles page navigation. Both use the same route mapping:
// Native side: switch tab, then tell web to navigate
func handleResolvedRoute(_ route: DeepLinkRoute) {
tabBarController.switchToTab(route.nativeTab)
webView.evaluateJavaScript(
"window.navigateFromDeepLink('\(route.webPath)')"
)
}
Challenge 3: WKWebView and Universal Links
On iOS, WKWebView has specific behavior with Universal Links. Links tapped inside a WKWebView that match your Associated Domains entitlement will not trigger Universal Link handling by default. Instead, the WebView navigates to the URL as a regular web page.
This matters for hybrid apps because internal links within your app's WebView content might match your deep link patterns. If a user taps a link to https://yourdomain.com/products/456 inside the WebView, you want the web router to handle it, not a Universal Link callback.
Solution: Navigation Policy Delegate
Use WKNavigationDelegate to intercept link taps and decide whether to handle them in-app:
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url,
url.host == "yourdomain.com" else {
decisionHandler(.allow)
return
}
// Handle internally via JavaScript router
if isDeepLinkPath(url.path) {
webView.evaluateJavaScript(
"window.handleDeepLink('\(url.absoluteString)')"
)
decisionHandler(.cancel) // Prevent WebView navigation
} else {
decisionHandler(.allow) // Let WebView load it
}
}
On Android, WebView and App Links have a similar consideration. Use shouldOverrideUrlLoading in your WebViewClient:
override fun shouldOverrideUrlLoading(
view: WebView, request: WebResourceRequest
): Boolean {
val url = request.url
if (url.host == "yourdomain.com" && isDeepLinkPath(url.path)) {
view.evaluateJavascript(
"window.handleDeepLink('${url}')", null
)
return true // Intercept
}
return false // Let WebView handle it
}
Challenge 4: Deep Link State Persistence
When a deep link opens the app, the user expects to continue their session after interacting with the linked content. In hybrid apps, navigating to a deep link target can destroy the existing WebView state (form inputs, scroll position, local variables).
Solution: History Stack Management
Push deep link destinations onto the navigation stack rather than replacing the root:
function handleDeepLink(url: string) {
const parsed = new URL(url);
const path = parsed.pathname + parsed.search;
// Push onto stack (preserves back navigation)
router.push(path);
// Do NOT use router.replace() unless the app just launched
}
function handleColdStartDeepLink(url: string) {
const parsed = new URL(url);
const path = parsed.pathname + parsed.search;
// On cold start, replace since there's no meaningful history
router.replace(path);
}
Challenge 5: Multiple WebView Instances
Some hybrid architectures use multiple WebView instances (one per tab, or a main WebView plus modal WebViews). Deep links need to target the correct instance.
// Route deep links to the correct WebView
function routeToWebView(route: DeepLinkRoute): WebView {
switch (route.nativeTab) {
case 'shop':
return webViews.shop;
case 'deals':
return webViews.deals;
case 'account':
return webViews.account;
default:
return webViews.main;
}
}
Testing Hybrid Deep Links
Testing must cover both layers independently and together.
Native Layer Tests
Verify that the native side correctly receives and forwards URLs:
# iOS
xcrun simctl openurl booted "https://yourdomain.com/products/123"
# Android
adb shell am start -a android.intent.action.VIEW \
-d "https://yourdomain.com/products/123" \
-c android.intent.category.BROWSABLE
Web Layer Tests
Test the JavaScript routing logic in isolation:
describe('DeepLinkRouter', () => {
it('resolves product links', () => {
const route = resolveRoute(
new URL('https://yourdomain.com/products/abc123')
);
expect(route).toEqual({
nativeTab: 'shop',
webPath: '/products/abc123',
params: {}
});
});
it('passes query parameters', () => {
const route = resolveRoute(
new URL('https://yourdomain.com/products/abc123?ref=email')
);
expect(route?.params.ref).toBe('email');
});
});
End-to-End Tests
Use platform testing tools to verify the full flow:
# Verify the link opens the app (not the browser)
# Then check the WebView navigated to the correct page
# iOS: use UI testing
# Android: use Espresso with Intents
Framework-Specific Notes
| Framework | Bridge Mechanism | Cold Start Handling |
|---|---|---|
| Capacitor | @capacitor/app plugin |
App.getLaunchUrl() |
| Cordova | ionic-plugin-deeplinks |
Plugin's route() method |
| Custom WebView | JavaScript bridge | Manual queue and replay |
| React Native WebView | onNavigationStateChange |
Props-based URL injection |
Checklist
Before shipping deep links in a hybrid app:
- AASA file hosted and valid (test with
curl -v https://yourdomain.com/.well-known/apple-app-site-association) - assetlinks.json hosted and valid
- Associated Domains entitlement includes
applinks:yourdomain.com - Android Intent filters include
android:autoVerify="true" - Native-to-web bridge handles cold start (queued URL replay)
- Web router matches all deep link paths
- Navigation state stays consistent (correct tab, correct back stack)
- WKWebView navigation delegate intercepts internal deep link URLs
- Android WebViewClient intercepts internal deep link URLs
- Query parameters and fragments are preserved through the bridge
Tolinku for Hybrid Apps
Tolinku hosts AASA and assetlinks.json files, handles deferred deep linking, and provides click analytics. See the deep linking documentation for integration details.
For Capacitor apps specifically, see Capacitor deep linking: setup for Ionic apps. For the full cross-platform overview, see cross-platform deep linking guide for 2026.
Get deep linking tips in your inbox
One email per week. No spam.