Deep Linking in React Native: Universal Links, App Links, and Deferred Deep Links
Deep links are URLs that open your app to a specific screen - not just the home screen. A link in an email that opens the app directly to a product page. A social share that opens to the shared post. A password reset link that opens to the reset form.
Implemented correctly, deep links improve conversion rates significantly - users who click a deep link and land on the relevant screen convert 2-3x better than users who land on the home screen and have to navigate.
Implemented incorrectly, they fail silently - the user opens the app but lands on the home screen, the link is lost, and the business value evaporates.
Here's the complete guide.
Three types of deep links
1. URI Schemes (custom URL schemes)
myapp://products/123
myapp://profile/settings
Simple, easy to implement, work on iOS and Android. Major limitation: any app can register the same URI scheme. myapp:// could be claimed by a malicious app. The system doesn't verify that myapp:// links should go to your app. For this reason, iOS and Android have introduced verified link schemes.
2. Universal Links (iOS) / App Links (Android)
https://yourapp.com/products/123
https://yourapp.com/profile/settings
These look like regular HTTPS URLs. The OS verifies that your app "owns" this domain via a well-known file on the domain. If the app is installed, the OS routes the link to the app. If not installed, it opens the browser to your web URL.
This is the correct approach for production apps.
3. Deferred Deep Links
The hardest case: user sees a deep link but doesn't have the app installed. They go to the App Store, install the app, and then open it. The deep link context is gone.
Deferred deep links preserve the original link through the install flow and deliver it on first open. This is critical for marketing campaigns, referral programs, and shareable content.
Setting up Universal Links (iOS)
Step 1: AASA file on your domain
Create https://yourapp.com/.well-known/apple-app-site-association (no .json extension):
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.yourcompany.yourapp",
"paths": [
"/products/*",
"/profile/*",
"/orders/*",
"/invite/*",
"NOT /blog/*",
"NOT /admin/*"
]
}
]
}
}
The TEAMID is your Apple Developer Team ID (10-character alphanumeric string found in App Store Connect). This file must be served over HTTPS with Content-Type: application/json.
Step 2: Enable Associated Domains in Xcode
In your Xcode project → Signing & Capabilities → Add Capability → Associated Domains:
applinks:yourapp.com
applinks:www.yourapp.com
Step 3: iOS app delegate
// AppDelegate.mm
- (BOOL)application:(UIApplication *)application
continueUserActivity:(NSUserActivity *)userActivity
restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler
{
return [RCTLinkingManager application:application
continueUserActivity:userActivity
restorationHandler:restorationHandler];
}
Setting up App Links (Android)
Step 1: assetlinks.json on your domain
Create https://yourapp.com/.well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.yourapp",
"sha256_cert_fingerprints": [
"AB:CD:EF:12:34:56:..."
]
}
}]
Get your SHA-256 fingerprint:
# Debug keystore (for development)
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android
# Production keystore
keytool -list -v -keystore your-release-key.keystore -alias your-key-alias
Important: You need both debug and production fingerprints in the file for proper testing.
Step 2: AndroidManifest.xml
<activity
android:name=".MainActivity"
android:launchMode="singleTask">
<!-- URI scheme (fallback) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
<!-- App Links (verified HTTPS) -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="yourapp.com"
android:pathPrefix="/products" />
<data
android:scheme="https"
android:host="yourapp.com"
android:pathPrefix="/profile" />
</intent-filter>
</activity>
React Native: handling deep links
import { Linking } from 'react-native';
import { NavigationContainerRef } from '@react-navigation/native';
export function useDeepLinking(navigationRef: NavigationContainerRef) {
useEffect(() => {
// Handle link when app is already open (foreground)
const subscription = Linking.addEventListener('url', ({ url }) => {
handleDeepLink(url, navigationRef);
});
// Handle link that opened the app from background/killed state
Linking.getInitialURL().then((url) => {
if (url) {
// Wait for navigation to be ready
const interval = setInterval(() => {
if (navigationRef.isReady()) {
clearInterval(interval);
handleDeepLink(url, navigationRef);
}
}, 100);
}
});
return () => subscription.remove();
}, []);
}
function handleDeepLink(url: string, navigationRef: NavigationContainerRef) {
const parsed = parseDeepLink(url);
if (!parsed) return;
switch (parsed.screen) {
case 'product':
navigationRef.navigate('ProductDetail', { productId: parsed.params.id });
break;
case 'profile':
navigationRef.navigate('Profile', { userId: parsed.params.userId });
break;
case 'order':
// Requires authentication
if (!isAuthenticated()) {
// Save link, redirect to login, then navigate after login
storePendingDeepLink(url);
navigationRef.navigate('Login');
} else {
navigationRef.navigate('OrderDetail', { orderId: parsed.params.id });
}
break;
}
}
function parseDeepLink(url: string): { screen: string; params: Record<string, string> } | null {
try {
const parsed = new URL(url);
const path = parsed.pathname;
// Handle both https://yourapp.com/products/123 and myapp://products/123
const productMatch = path.match(/^\/products\/([^/]+)/);
if (productMatch) return { screen: 'product', params: { id: productMatch[1] } };
const profileMatch = path.match(/^\/profile\/([^/]+)/);
if (profileMatch) return { screen: 'profile', params: { userId: profileMatch[1] } };
const orderMatch = path.match(/^\/orders\/([^/]+)/);
if (orderMatch) return { screen: 'order', params: { id: orderMatch[1] } };
return null;
} catch {
return null;
}
}
Deferred deep links
For deferred deep links (preserving link through install), you have two options:
Option A: Branch.io or Firebase Dynamic Links
Firebase Dynamic Links was deprecated in 2025. Branch.io is the current standard.
import branch from 'react-native-branch';
// Create a shareable deep link
async function createShareLink(productId: string, userId: string) {
const branchUniversalObject = await branch.createBranchUniversalObject(
`product/${productId}`,
{
title: 'Check out this product',
contentImageUrl: 'https://yourapp.com/products/${productId}/image',
contentMetadata: {
customMetadata: {
productId,
referrerId: userId,
},
},
}
);
const { url } = await branchUniversalObject.generateShortUrl({
feature: 'share',
channel: 'app',
campaign: 'product_share',
}, {
$desktop_url: `https://yourapp.com/products/${productId}`,
$ios_url: `https://yourapp.com/products/${productId}`,
});
return url; // Something like: https://yourapp.app.link/abc123
}
// On app first open, receive the deferred link
useEffect(() => {
branch.subscribe(({ error, params }) => {
if (error) return;
if (!params['+clicked_branch_link']) return;
// This fires on first open after install if user came from a Branch link
const { productId, referrerId } = params;
if (productId) {
navigationRef.navigate('ProductDetail', { productId });
}
if (referrerId) {
// Credit referral
api.post('/referrals', { referrerId });
}
});
}, []);
Option B: Custom deferred deep links via fingerprinting
If you don't want a third-party service, you can implement basic deferred deep links using device fingerprinting:
// When user clicks link on web (before app install):
// Store link data in your backend associated with device fingerprint
// (IP + User-Agent hash - not reliable for 100% of cases but works for ~70%)
// On first app open:
async function checkDeferredDeepLink() {
const deviceInfo = await getDeviceFingerprint();
const response = await api.post('/deferred-deep-links/check', {
fingerprint: deviceInfo,
installTime: new Date().toISOString(),
});
if (response.pendingLink) {
handleDeepLink(response.pendingLink, navigationRef);
}
}
Fingerprinting is ~70% accurate. Branch.io is ~95%+ accurate and handles edge cases like VPN users, multiple clicks. For any growth-critical use case (referral programs, marketing campaigns), Branch or a similar service is worth the cost.
Testing Universal Links and App Links
iOS Universal Links cannot be tested in the simulator. Test on a physical device. The OS caches the AASA file aggressively - to force a re-fetch after changing it:
# Reset AASA cache on device (iOS 16+)
# Settings → Developer → Reset Universal Links Cache
# Or: hold down the link in Messages/Notes for a preview,
# which forces a re-fetch
Android App Links verification:
# Check App Link verification status
adb shell pm get-app-links com.yourcompany.yourapp
# Expected output:
# com.yourcompany.yourapp:
# ID: 1
# Signatures: [SHA256_CERT_FINGERPRINT]
# Domain verification state:
# yourapp.com: verified ← this is what you want
Common failure: assetlinks.json not served correctly
# Verify the file is accessible
curl -v https://yourapp.com/.well-known/assetlinks.json
# Must return:
# Content-Type: application/json
# No redirect (some servers redirect, which breaks verification)
# Status 200
If your server redirects www.yourapp.com to yourapp.com, you need the assetlinks.json accessible on BOTH domains, or App Links verification will fail for the redirected domain.
The authenticated deep link pattern
Many deep links require the user to be logged in (order status, profile edit, payment confirmation). Never drop the deep link if the user is not authenticated:
// 1. Store the pending deep link before redirecting to login
function navigateToDeepLink(url: string) {
if (requiresAuth(url) && !store.getState().auth.isLoggedIn) {
store.dispatch(setPendingDeepLink(url));
navigation.navigate('Login');
return;
}
handleDeepLink(url, navigation);
}
// 2. After successful login, check for pending link
function onLoginSuccess() {
const pendingLink = store.getState().navigation.pendingDeepLink;
if (pendingLink) {
store.dispatch(clearPendingDeepLink());
handleDeepLink(pendingLink, navigation);
} else {
navigation.navigate('Home');
}
}
This pattern is often omitted from initial implementations and causes significant drop-off in email campaigns where users aren't logged in when they click links.
Analytics: what to track
// Track every deep link open with full attribution
analytics.track('deep_link_opened', {
url: originalUrl,
screen: parsedRoute.screen,
params: parsedRoute.params,
source: params['~channel'] || 'direct', // Branch attribution
campaign: params['~campaign'],
wasInstalled: !wasAlreadyInstalled, // Deferred = true
userId: currentUserId,
});
Key metrics to monitor:
- Deep link open rate by source (email, push, social)
- Deferred install rate from marketing campaigns
- Navigation success rate (did the link successfully navigate to the target screen?)
- Drop-off at authentication wall (how many users don't complete login after a deep link?)
Deep links that silently fail to navigate correctly are a common bug - users land on the home screen, don't realize anything went wrong, and you lose the conversion. The analytics catch this.
Aunimeda develops mobile applications for iOS and Android - from MVP to production-ready apps with full backend integration.
Contact us to discuss your mobile project. See also: Mobile App Development, Mobile Game Development