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

Deep Linking in Hybrid Apps: Challenges and Solutions

By Tolinku Staff
|
Tolinku webhooks integrations dashboard screenshot for engineering blog posts

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.

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:

  1. iOS calls application(_:continue:restorationHandler:) on the AppDelegate.
  2. Android delivers an Intent with the URL to the Activity.
  3. The native layer forwards the URL to the WebView or JavaScript bridge.
  4. 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:

  1. Switch to the correct native tab.
  2. Navigate the web router within that tab to the product page.
  3. 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)')"
    )
}

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
}

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 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.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.