Deep Linking Guide
Complete guide for implementing deep links and universal links in your Expo app.
Table of Contents
- Overview
- URL Scheme Setup
- Universal Links (iOS)
- App Links (Android)
- Handling Links
- Testing
- Common Patterns
Overview
Types of Links
| Type | Format | Use Case |
|---|---|---|
| URL Scheme | myapp://path | App-to-app communication |
| Universal Links (iOS) | https://myapp.com/path | Web fallback, secure |
| App Links (Android) | https://myapp.com/path | Web fallback, verified |
Current Configuration
// app.json
{
"expo": {
"scheme": "rork-app"
}
}
This enables: rork-app:// links
URL Scheme Setup
1. Configure Scheme
// app.json
{
"expo": {
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.yourcompany.myapp"
},
"android": {
"package": "com.yourcompany.myapp"
}
}
}
2. Link Format
myapp:// → Opens app to root
myapp://home → Opens /home route
myapp://profile/123 → Opens /profile/123 route
myapp://settings?tab=theme → Opens /settings with query params
3. Multiple Schemes (Optional)
{
"expo": {
"scheme": ["myapp", "myapp-dev"]
}
}
Universal Links (iOS)
Universal Links allow https:// URLs to open your app.
1. Configure in app.json
{
"expo": {
"ios": {
"associatedDomains": [
"applinks:yourapp.com",
"applinks:www.yourapp.com"
]
}
}
}
2. Host apple-app-site-association
Create file at https://yourapp.com/.well-known/apple-app-site-association:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.com.yourcompany.myapp",
"paths": [
"/product/*",
"/user/*",
"/invite/*",
"/share/*"
]
}
]
}
}
Requirements:
- File must be served over HTTPS
- Content-Type:
application/json - No file extension
- No redirects
3. Find Your Team ID
- Go to Apple Developer Portal
- Membership → Team ID
App Links (Android)
App Links allow verified https:// URLs to open your app.
1. Configure in app.json
{
"expo": {
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "yourapp.com",
"pathPrefix": "/product"
},
{
"scheme": "https",
"host": "yourapp.com",
"pathPrefix": "/user"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
2. Host assetlinks.json
Create file at https://yourapp.com/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.myapp",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}
]
3. Get SHA256 Fingerprint
# For debug keystore
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
# For EAS builds
eas credentials --platform android
Handling Links
1. Basic Link Handler
The boilerplate includes +native-intent.tsx for handling incoming links:
// app/+native-intent.tsx
export function redirectSystemPath({
path,
initial,
}: {
path: string;
initial: boolean;
}) {
// Parse and redirect incoming links
console.log('Deep link received:', path, 'Initial:', initial);
// Return the path to navigate to
// Return '/' to go to home
// Return path to navigate to that route
if (path.startsWith('/invite/')) {
// Handle invite links specially
const inviteCode = path.replace('/invite/', '');
// Store invite code and redirect
return '/onboarding';
}
return path || '/';
}
2. Using Expo Linking API
import * as Linking from 'expo-linking';
import { useEffect } from 'react';
// Get the initial URL (app opened via link)
const useInitialURL = () => {
const [initialURL, setInitialURL] = useState<string | null>(null);
useEffect(() => {
Linking.getInitialURL().then((url) => {
if (url) {
console.log('App opened with URL:', url);
setInitialURL(url);
}
});
}, []);
return initialURL;
};
// Listen for incoming links while app is open
const useLinkListener = (onLink: (url: string) => void) => {
useEffect(() => {
const subscription = Linking.addEventListener('url', ({ url }) => {
console.log('Received link:', url);
onLink(url);
});
return () => subscription.remove();
}, [onLink]);
};
3. Parse Link Parameters
import * as Linking from 'expo-linking';
function parseDeepLink(url: string) {
const { path, queryParams } = Linking.parse(url);
console.log('Path:', path); // e.g., "product/123"
console.log('Params:', queryParams); // e.g., { ref: "email" }
return { path, queryParams };
}
// Example URLs:
// myapp://product/123?ref=email
// → path: "product/123", queryParams: { ref: "email" }
// https://yourapp.com/user/456
// → path: "user/456", queryParams: {}
4. Create Shareable Links
import * as Linking from 'expo-linking';
// Create URL scheme link
const createAppLink = (path: string, params?: Record<string, string>) => {
return Linking.createURL(path, { queryParams: params });
// Returns: myapp://product/123?ref=share
};
// Create universal link
const createUniversalLink = (path: string) => {
return `https://yourapp.com${path}`;
// Returns: https://yourapp.com/product/123
};
// Share link
import * as Sharing from 'expo-sharing';
const shareProduct = async (productId: string) => {
const link = createUniversalLink(`/product/${productId}`);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(link, {
dialogTitle: 'Share Product',
});
}
};
5. Deep Link Context (Advanced)
// contexts/DeepLinkContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import * as Linking from 'expo-linking';
import { useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
interface PendingLink {
path: string;
params: Record<string, string>;
}
export const [DeepLinkProvider, useDeepLink] = createContextHook(() => {
const router = useRouter();
const [pendingLink, setPendingLink] = useState<PendingLink | null>(null);
const [isReady, setIsReady] = useState(false);
// Handle initial URL
useEffect(() => {
Linking.getInitialURL().then((url) => {
if (url) {
handleLink(url);
}
});
}, []);
// Listen for links while app is open
useEffect(() => {
const subscription = Linking.addEventListener('url', ({ url }) => {
handleLink(url);
});
return () => subscription.remove();
}, [isReady]);
const handleLink = (url: string) => {
const { path, queryParams } = Linking.parse(url);
const linkData = {
path: path || '/',
params: (queryParams as Record<string, string>) || {},
};
if (isReady) {
// Navigate immediately if app is ready
navigateToLink(linkData);
} else {
// Store for later if app isn't ready
setPendingLink(linkData);
}
};
const navigateToLink = (link: PendingLink) => {
console.log('Navigating to deep link:', link);
router.push({
pathname: link.path as any,
params: link.params,
});
};
const setAppReady = () => {
setIsReady(true);
if (pendingLink) {
navigateToLink(pendingLink);
setPendingLink(null);
}
};
const createShareLink = (path: string, params?: Record<string, string>) => {
// Prefer universal links for sharing
const queryString = params
? '?' + new URLSearchParams(params).toString()
: '';
return `https://yourapp.com${path}${queryString}`;
};
return {
pendingLink,
setAppReady,
createShareLink,
};
});
Testing
Test URL Scheme Links
# iOS Simulator
xcrun simctl openurl booted "myapp://product/123"
# Android Emulator
adb shell am start -a android.intent.action.VIEW -d "myapp://product/123"
# Expo Go
npx uri-scheme open "myapp://product/123" --ios
npx uri-scheme open "myapp://product/123" --android
Test Universal/App Links
# iOS - must use Safari
# Open Safari in simulator and navigate to your URL
# Android
adb shell am start -a android.intent.action.VIEW -d "https://yourapp.com/product/123"
Verify Configuration
iOS:
# Check apple-app-site-association
curl -I https://yourapp.com/.well-known/apple-app-site-association
# Should return:
# Content-Type: application/json
# No redirects
Android:
# Check assetlinks.json
curl https://yourapp.com/.well-known/assetlinks.json
# Verify with Google
https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://yourapp.com&relation=delegate_permission/common.handle_all_urls
Common Patterns
1. Invite/Referral Links
// Generate invite link
const createInviteLink = (userId: string) => {
return `https://yourapp.com/invite/${userId}`;
};
// Handle invite link
// In +native-intent.tsx or link handler:
if (path.startsWith('/invite/')) {
const referrerId = path.replace('/invite/', '');
await AsyncStorage.setItem('referrer_id', referrerId);
return '/onboarding';
}
2. Product/Content Links
// Share product
const shareProduct = (productId: string) => {
const link = `https://yourapp.com/product/${productId}`;
Share.share({ url: link, message: 'Check out this product!' });
};
// Route mapping happens automatically with Expo Router
// /product/[id].tsx handles /product/123
3. Authentication Links
// Email magic link
const sendMagicLink = async (email: string) => {
const token = generateToken();
const link = `https://yourapp.com/auth/verify?token=${token}`;
await sendEmail(email, link);
};
// Handle magic link
// In +native-intent.tsx:
if (path.startsWith('/auth/verify')) {
const { queryParams } = Linking.parse(fullUrl);
if (queryParams?.token) {
await verifyToken(queryParams.token);
return '/home';
}
}
4. Password Reset Links
// Send reset link
const sendResetLink = async (email: string) => {
const token = generateResetToken();
const link = `https://yourapp.com/reset-password?token=${token}`;
await sendEmail(email, link);
};
// Handle in app
// Create app/reset-password.tsx to handle the route
5. Marketing Campaign Links
// Create trackable link
const createCampaignLink = (campaign: string, source: string) => {
return `https://yourapp.com/download?utm_campaign=${campaign}&utm_source=${source}`;
};
// Track in analytics
if (queryParams?.utm_campaign) {
analytics.track('campaign_open', {
campaign: queryParams.utm_campaign,
source: queryParams.utm_source,
});
}
Troubleshooting
Link Not Opening App
- Check scheme is correct in app.json
- Rebuild the app after changing scheme
- Check URL format matches expected pattern
- Universal links: Verify AASA file is accessible
- App links: Verify assetlinks.json and SHA256
Universal Links Opening in Browser
- Long-press the link and select "Open in App"
- Check AASA file format and accessibility
- Ensure paths match exactly
- Wait for iOS to re-fetch AASA (can take time)
App Links Not Verified
- Check assetlinks.json format
- Verify SHA256 fingerprint matches
- Ensure
autoVerify: trueis set - Check for redirects on the JSON file URL