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_VIEWdelivered to the Activity - Web:
window.locationparsed on page load - Capacitor/Ionic:
App.addListener('appUrlOpen', ...) - React Native:
Linking.addEventListener('url', ...) - Flutter:
uni_linksorapp_linkspackage
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:
- AASA file (iOS path patterns)
- assetlinks.json (Android verification)
- AndroidManifest.xml (Intent filter path prefixes)
- App router (client-side route definitions)
- 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.