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

Cross-Platform Link Handling Patterns

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

Deep link handling requires platform-specific entry points (Universal Links on iOS, App Links on Android, URL routing on web) but the actual routing logic, the part that decides "this URL maps to this screen with these parameters," can be shared. This article covers patterns for writing link handling code once and adapting it per platform.

For the complete cross-platform overview, see cross-platform deep linking guide for 2026. For routing fundamentals, see deep link routing: how to route users to the right screen.

The Problem

Each platform has its own API for receiving deep links:

  • iOS: application(_:continue:restorationHandler:) in AppDelegate or SceneDelegate
  • Android: Intent with ACTION_VIEW delivered to the Activity
  • Web: window.location parsed on page load
  • Capacitor/Ionic: App.addListener('appUrlOpen', ...)
  • React Native: Linking.addEventListener('url', ...)
  • Flutter: uni_links or app_links package

The URL arrives through different APIs, but the question is always the same: given this path and these query parameters, which screen should the user see?

Pattern 1: Shared Route Table

Define routes in a platform-agnostic format. Each platform's entry point parses the URL and passes it through the shared table.

Route Definition

// routes.ts (shared across all platforms)
interface RouteMatch {
  screen: string;
  params: Record<string, string>;
}

interface RoutePattern {
  pattern: RegExp;
  screen: string;
  paramNames: string[];
}

const routes: RoutePattern[] = [
  {
    pattern: /^\/products\/([^/]+)$/,
    screen: 'ProductDetail',
    paramNames: ['productId']
  },
  {
    pattern: /^\/offers\/([^/]+)$/,
    screen: 'OfferDetail',
    paramNames: ['offerId']
  },
  {
    pattern: /^\/referral\/([^/]+)$/,
    screen: 'Referral',
    paramNames: ['referrerId']
  },
  {
    pattern: /^\/categories\/([^/]+)\/products$/,
    screen: 'CategoryProducts',
    paramNames: ['categoryId']
  },
  {
    pattern: /^\/search$/,
    screen: 'Search',
    paramNames: []
  }
];

export function matchRoute(urlString: string): RouteMatch | null {
  const url = new URL(urlString);
  const path = url.pathname;

  for (const route of routes) {
    const match = path.match(route.pattern);
    if (match) {
      const params: Record<string, string> = {};
      route.paramNames.forEach((name, i) => {
        params[name] = match[i + 1];
      });

      // Include query parameters
      url.searchParams.forEach((value, key) => {
        params[key] = value;
      });

      return { screen: route.screen, params };
    }
  }

  return null;
}

Platform-Specific Entry Points

iOS (Swift):

func application(_ application: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard let url = userActivity.webPageUrl else { return false }

    // Call shared routing logic (via JavaScript bridge, or re-implement in Swift)
    let route = DeepLinkRouter.match(url: url)
    if let route = route {
        navigate(to: route.screen, params: route.params)
        return true
    }
    return false
}

Android (Kotlin):

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    intent?.data?.let { uri ->
        val route = DeepLinkRouter.match(uri.toString())
        route?.let { navigateTo(it.screen, it.params) }
    }
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    intent.data?.let { uri ->
        val route = DeepLinkRouter.match(uri.toString())
        route?.let { navigateTo(it.screen, it.params) }
    }
}

Web:

// On page load or route change
const route = matchRoute(window.location.href);
if (route) {
  renderScreen(route.screen, route.params);
}

Pattern 2: URL-to-Action Mapping

Instead of mapping URLs directly to screens, map them to actions. This decouples the deep link layer from the navigation layer.

// actions.ts
type DeepLinkAction =
  | { type: 'VIEW_PRODUCT'; productId: string; source?: string }
  | { type: 'VIEW_OFFER'; offerId: string }
  | { type: 'APPLY_REFERRAL'; referrerId: string }
  | { type: 'SEARCH'; query: string }
  | { type: 'OPEN_HOME' };

export function urlToAction(urlString: string): DeepLinkAction {
  const url = new URL(urlString);
  const path = url.pathname;
  const query = url.searchParams;

  const productMatch = path.match(/^\/products\/([^/]+)$/);
  if (productMatch) {
    return {
      type: 'VIEW_PRODUCT',
      productId: productMatch[1],
      source: query.get('ref') ?? undefined
    };
  }

  const offerMatch = path.match(/^\/offers\/([^/]+)$/);
  if (offerMatch) {
    return { type: 'VIEW_OFFER', offerId: offerMatch[1] };
  }

  const referralMatch = path.match(/^\/referral\/([^/]+)$/);
  if (referralMatch) {
    return { type: 'APPLY_REFERRAL', referrerId: referralMatch[1] };
  }

  if (path === '/search') {
    return { type: 'SEARCH', query: query.get('q') ?? '' };
  }

  return { type: 'OPEN_HOME' };
}

Each platform implements its own action handler:

// iOS, Android, web each implement this interface
interface ActionHandler {
  handleAction(action: DeepLinkAction): void;
}

// Example: React Native handler
class RNActionHandler implements ActionHandler {
  handleAction(action: DeepLinkAction) {
    switch (action.type) {
      case 'VIEW_PRODUCT':
        navigation.navigate('ProductDetail', {
          productId: action.productId
        });
        break;
      case 'APPLY_REFERRAL':
        // Store referral, then navigate
        ReferralStore.save(action.referrerId);
        navigation.navigate('Home');
        break;
      // ...
    }
  }
}

This pattern is useful when some actions require side effects (storing a referral code, triggering an analytics event) before navigation.

Pattern 3: Configuration-Driven Routes

For apps with many deep link paths, define routes in a configuration file that all platforms read:

{
  "routes": [
    {
      "path": "/products/:productId",
      "screen": "ProductDetail",
      "requiresAuth": false
    },
    {
      "path": "/account/settings",
      "screen": "AccountSettings",
      "requiresAuth": true
    },
    {
      "path": "/offers/:offerId",
      "screen": "OfferDetail",
      "requiresAuth": false
    },
    {
      "path": "/checkout/:cartId",
      "screen": "Checkout",
      "requiresAuth": true
    }
  ]
}

A shared parser converts path patterns to regex:

function pathToRegex(path: string): { regex: RegExp; paramNames: string[] } {
  const paramNames: string[] = [];
  const regexStr = path.replace(/:([^/]+)/g, (_, name) => {
    paramNames.push(name);
    return '([^/]+)';
  });
  return {
    regex: new RegExp(`^${regexStr}$`),
    paramNames
  };
}

This approach keeps route definitions in sync across platforms. If you add a new deep link path, update the config file once.

Handling Platform Differences

Even with shared routing logic, some behaviors differ between platforms.

Authentication Gates

When a deep link targets an authenticated screen, the behavior depends on platform conventions:

function handleAuthenticatedRoute(
  route: RouteMatch,
  isLoggedIn: boolean,
  platform: 'ios' | 'android' | 'web'
) {
  if (isLoggedIn) {
    navigate(route.screen, route.params);
    return;
  }

  // Save the intended destination
  saveDeepLinkDestination(route);

  // Platform-specific login flow
  if (platform === 'web') {
    // Redirect to login page with return URL
    window.location.href = `/login?returnTo=${encodeURIComponent(route.screen)}`;
  } else {
    // Show native login screen, then navigate after success
    showLoginScreen();
  }
}

Fallback Behavior

When a route does not match, each platform has a different fallback:

Platform Fallback
iOS Open home screen or show error
Android Open home screen or let system handle (browser)
Web Show 404 page or redirect to home
function handleUnmatchedUrl(url: string, platform: string) {
  if (platform === 'web') {
    // Web can show a 404 page
    router.navigate('/not-found');
  } else {
    // Mobile apps should gracefully degrade to home
    router.navigate('/');
  }
}

Keeping Routes in Sync

The hardest part of cross-platform link handling is keeping routes synchronized across:

  1. AASA file (iOS path patterns)
  2. assetlinks.json (Android verification)
  3. AndroidManifest.xml (Intent filter path prefixes)
  4. App router (client-side route definitions)
  5. Backend (server-side route handling for web)

Single Source of Truth

Generate platform-specific configuration from a single route definition:

// generate-configs.ts
import routeConfig from './routes.json';

// Generate AASA components
function generateAASA(routes: Route[]) {
  return {
    applinks: {
      details: [{
        appIDs: ['TEAMID.com.yourcompany.yourapp'],
        components: routes.map(r => ({
          '/': r.path.replace(/:([^/]+)/g, '*')
        }))
      }]
    }
  };
}

// Generate Android Intent filter paths
function generateIntentFilters(routes: Route[]) {
  // Extract unique path prefixes
  const prefixes = new Set(
    routes.map(r => '/' + r.path.split('/')[1])
  );
  return Array.from(prefixes);
}

This prevents the common bug where a new route works on one platform but not another because someone forgot to update the AASA or Intent filters.

Testing Cross-Platform Routes

Test the shared routing logic with platform-specific test URLs:

describe('matchRoute', () => {
  const testCases = [
    {
      url: 'https://yourdomain.com/products/abc123',
      expected: { screen: 'ProductDetail', params: { productId: 'abc123' } }
    },
    {
      url: 'https://yourdomain.com/products/abc123?ref=email&campaign=summer',
      expected: {
        screen: 'ProductDetail',
        params: { productId: 'abc123', ref: 'email', campaign: 'summer' }
      }
    },
    {
      url: 'https://yourdomain.com/unknown/path',
      expected: null
    }
  ];

  testCases.forEach(({ url, expected }) => {
    it(`matches ${url}`, () => {
      expect(matchRoute(url)).toEqual(expected);
    });
  });
});

Tolinku for Cross-Platform Apps

Tolinku handles AASA and assetlinks.json hosting, so your verification files stay in sync automatically. The platform also provides deferred deep linking for users who need to install the app first.

For hybrid app challenges, see deep linking in hybrid apps: challenges and solutions. For the complete 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.