Implementing Deep Linking and Universal Links in React Native

React Native|OCTOBER 11, 2025|0 VIEWS
Master deep linking and universal links in React Native - from basic URL schemes to production-ready iOS Universal Links and Android App Links implementation

Introduction

Deep linking is a crucial feature for modern mobile applications, enabling users to navigate directly to specific content within your app from external sources like emails, SMS messages, social media posts, or web pages. Whether you're implementing marketing campaigns, social sharing, or seamless user experiences, deep linking is essential for user engagement and retention.

In this comprehensive guide, we'll cover everything you need to know about implementing deep linking in React Native:

  • Understanding the difference between deep links, universal links, and app links
  • Setting up basic URL schemes for deep linking
  • Implementing iOS Universal Links with proper configuration
  • Implementing Android App Links with domain verification
  • Handling navigation and routing with React Navigation
  • Testing deep links in development and production
  • Security best practices and common pitfalls
  • Troubleshooting tips for production apps

By the end of this tutorial, you'll have a production-ready deep linking system that works seamlessly across both iOS and Android platforms.

Understanding Deep Linking Types

URL schemes are the simplest form of deep linking. They use custom URI schemes like myapp:// to open your app.

Example:

myapp://product/123
myapp://profile/john-doe

Pros:

  • Easy to implement
  • Works on both platforms
  • No server configuration required

Cons:

  • Not secure (any app can register the same scheme)
  • Shows an interstitial dialog on some platforms
  • Doesn't fallback to web if app isn't installed
  • Can be hijacked by malicious apps

Universal Links are Apple's solution for secure, seamless deep linking using standard HTTPS URLs.

Example:

https://myapp.com/product/123
https://myapp.com/profile/john-doe

Pros:

  • Secure - verified by Apple through domain association
  • Seamless - opens app directly without dialogs
  • Fallback to website if app isn't installed
  • Better user experience
  • Better for SEO and marketing

Cons:

  • Requires server configuration (apple-app-site-association file)
  • More complex setup
  • Requires valid SSL certificate

App Links are Android's equivalent to Universal Links, providing verified HTTPS URL handling.

Example:

https://myapp.com/product/123
https://myapp.com/profile/john-doe

Pros:

  • Secure with domain verification
  • Direct app opening without disambiguation dialog
  • Fallback to website
  • Better integration with Android ecosystem

Cons:

  • Requires server configuration (assetlinks.json file)
  • Domain verification needed
  • More setup complexity

Project Setup

For this tutorial, we'll build a complete deep linking system for a sample e-commerce app. Let's start with the project setup.

Prerequisites

# Required tools
node >= 18.0.0
npm or yarn
React Native >= 0.70
Expo SDK >= 49 (if using Expo)

Install Dependencies

For a standard React Native project with React Navigation:

npm install @react-navigation/native @react-navigation/native-stack
npm install react-native-screens react-native-safe-area-context

For Expo:

npx expo install expo-linking expo-constants
npm install @react-navigation/native @react-navigation/native-stack

Implementing Basic URL Schemes

Let's start with basic URL scheme deep linking before moving to universal links.

Configure URL Scheme in React Native

iOS Configuration (Info.plist)

Open ios/YourApp/Info.plist and add:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleTypeRole</key>
    <string>Editor</string>
    <key>CFBundleURLName</key>
    <string>com.yourcompany.yourapp</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
      <string>com.yourcompany.yourapp</string>
    </array>
  </dict>
</array>

Android Configuration (AndroidManifest.xml)

Open android/app/src/main/AndroidManifest.xml and add an intent filter to your main activity:

<activity
  android:name=".MainActivity"
  android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
  android:launchMode="singleTask"
  android:windowSoftInputMode="adjustResize">
  
  <!-- Existing intent filter -->
  <intent-filter>
    <action android:name="android.intent.action.MAIN" />
    <category android:name="android.intent.category.LAUNCHER" />
  </intent-filter>
  
  <!-- Deep linking intent filter -->
  <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>
</activity>

Expo Configuration (app.json)

If using Expo, add to your app.json:

{
  "expo": {
    "scheme": "myapp",
    "ios": {
      "bundleIdentifier": "com.yourcompany.yourapp"
    },
    "android": {
      "package": "com.yourcompany.yourapp"
    }
  }
}

Setting Up React Navigation with Deep Linking

Create a navigation configuration with linking support:

// src/navigation/linking.ts
import { LinkingOptions } from '@react-navigation/native';
import * as Linking from 'expo-linking';

export type RootStackParamList = {
  Home: undefined;
  ProductDetails: { productId: string };
  Profile: { username: string };
  Category: { categoryId: string; sort?: string };
  Cart: undefined;
  Checkout: { orderId?: string };
  Settings: undefined;
  NotFound: undefined;
};

const linking: LinkingOptions<RootStackParamList> = {
  prefixes: [
    'myapp://',
    'https://myapp.com',
    'https://*.myapp.com',
  ],
  config: {
    screens: {
      Home: '',
      ProductDetails: {
        path: 'product/:productId',
        parse: {
          productId: (productId: string) => productId,
        },
      },
      Profile: {
        path: 'profile/:username',
        parse: {
          username: (username: string) => decodeURIComponent(username),
        },
      },
      Category: {
        path: 'category/:categoryId',
        parse: {
          categoryId: (categoryId: string) => categoryId,
          sort: (sort: string) => sort,
        },
      },
      Cart: 'cart',
      Checkout: {
        path: 'checkout/:orderId?',
        parse: {
          orderId: (orderId: string) => orderId || undefined,
        },
      },
      Settings: 'settings',
      NotFound: '*',
    },
  },
};

export default linking;

Create Navigation Container

// src/navigation/AppNavigator.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import linking, { RootStackParamList } from './linking';

// Import your screens
import HomeScreen from '../screens/HomeScreen';
import ProductDetailsScreen from '../screens/ProductDetailsScreen';
import ProfileScreen from '../screens/ProfileScreen';
import CategoryScreen from '../screens/CategoryScreen';
import CartScreen from '../screens/CartScreen';
import CheckoutScreen from '../screens/CheckoutScreen';
import SettingsScreen from '../screens/SettingsScreen';
import NotFoundScreen from '../screens/NotFoundScreen';

const Stack = createNativeStackNavigator<RootStackParamList>();

export default function AppNavigator() {
  return (
    <NavigationContainer linking={linking}>
      <Stack.Navigator initialRouteName="Home">
        <Stack.Screen 
          name="Home" 
          component={HomeScreen}
          options={{ title: 'Home' }}
        />
        <Stack.Screen 
          name="ProductDetails" 
          component={ProductDetailsScreen}
          options={{ title: 'Product Details' }}
        />
        <Stack.Screen 
          name="Profile" 
          component={ProfileScreen}
          options={{ title: 'Profile' }}
        />
        <Stack.Screen 
          name="Category" 
          component={CategoryScreen}
          options={{ title: 'Category' }}
        />
        <Stack.Screen 
          name="Cart" 
          component={CartScreen}
          options={{ title: 'Cart' }}
        />
        <Stack.Screen 
          name="Checkout" 
          component={CheckoutScreen}
          options={{ title: 'Checkout' }}
        />
        <Stack.Screen 
          name="Settings" 
          component={SettingsScreen}
          options={{ title: 'Settings' }}
        />
        <Stack.Screen 
          name="NotFound" 
          component={NotFoundScreen}
          options={{ title: 'Not Found' }}
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

For more control, you can handle deep links manually:

// src/hooks/useDeepLinking.ts
import { useEffect } from 'react';
import { Linking } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/linking';

type NavigationProp = NativeStackNavigationProp<RootStackParamList>;

export function useDeepLinking() {
  const navigation = useNavigation<NavigationProp>();

  useEffect(() => {
    // Handle initial URL (app was closed and opened via deep link)
    const getInitialURL = async () => {
      const initialUrl = await Linking.getInitialURL();
      if (initialUrl) {
        handleDeepLink(initialUrl);
      }
    };

    getInitialURL();

    // Handle URL when app is already open
    const subscription = Linking.addEventListener('url', ({ url }) => {
      handleDeepLink(url);
    });

    return () => {
      subscription.remove();
    };
  }, []);

  const handleDeepLink = (url: string) => {
    console.log('Deep link received:', url);

    // Parse the URL
    const { hostname, path, queryParams } = Linking.parse(url);

    // Route based on the path
    if (path?.startsWith('product/')) {
      const productId = path.split('/')[1];
      navigation.navigate('ProductDetails', { productId });
    } else if (path?.startsWith('profile/')) {
      const username = path.split('/')[1];
      navigation.navigate('Profile', { username });
    } else if (path?.startsWith('category/')) {
      const categoryId = path.split('/')[1];
      navigation.navigate('Category', { 
        categoryId,
        sort: queryParams?.sort as string 
      });
    } else if (path === 'cart') {
      navigation.navigate('Cart');
    } else if (path?.startsWith('checkout')) {
      const orderId = path.split('/')[1];
      navigation.navigate('Checkout', { orderId });
    } else if (path === 'settings') {
      navigation.navigate('Settings');
    } else {
      // Unknown path - navigate to home or show error
      console.warn('Unknown deep link path:', path);
      navigation.navigate('Home');
    }
  };
}

Example Screen Implementation

// src/screens/ProductDetailsScreen.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, Image, Button } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/linking';

type Props = NativeStackScreenProps<RootStackParamList, 'ProductDetails'>;

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  imageUrl: string;
}

export default function ProductDetailsScreen({ route, navigation }: Props) {
  const { productId } = route.params;
  const [product, setProduct] = useState<Product | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetchProduct();
  }, [productId]);

  const fetchProduct = async () => {
    try {
      setLoading(true);
      // Replace with your API call
      const response = await fetch(`https://api.myapp.com/products/${productId}`);
      
      if (!response.ok) {
        throw new Error('Product not found');
      }
      
      const data = await response.json();
      setProduct(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred');
      console.error('Error fetching product:', err);
    } finally {
      setLoading(false);
    }
  };

  if (loading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#007AFF" />
      </View>
    );
  }

  if (error || !product) {
    return (
      <View style={styles.centered}>
        <Text style={styles.errorText}>{error || 'Product not found'}</Text>
        <Button title="Go Back" onPress={() => navigation.goBack()} />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Image source={{ uri: product.imageUrl }} style={styles.image} />
      <Text style={styles.title}>{product.name}</Text>
      <Text style={styles.price}>${product.price.toFixed(2)}</Text>
      <Text style={styles.description}>{product.description}</Text>
      <Button 
        title="Add to Cart" 
        onPress={() => {
          // Add to cart logic
          navigation.navigate('Cart');
        }} 
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
    backgroundColor: '#fff',
  },
  centered: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  image: {
    width: '100%',
    height: 300,
    resizeMode: 'cover',
    borderRadius: 8,
    marginBottom: 16,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 8,
  },
  price: {
    fontSize: 20,
    color: '#007AFF',
    fontWeight: '600',
    marginBottom: 16,
  },
  description: {
    fontSize: 16,
    color: '#666',
    lineHeight: 24,
    marginBottom: 24,
  },
  errorText: {
    fontSize: 18,
    color: '#FF3B30',
    marginBottom: 16,
  },
});

Implementing iOS Universal Links

Universal Links provide a seamless experience on iOS by using HTTPS URLs that can open your app or fallback to your website.

Step 1: Configure Associated Domains in Xcode

  1. Open your project in Xcode: open ios/YourApp.xcworkspace
  2. Select your project target
  3. Go to "Signing & Capabilities" tab
  4. Click "+ Capability" and add "Associated Domains"
  5. Add your domains with the applinks: prefix:
applinks:myapp.com
applinks:www.myapp.com
applinks:*.myapp.com

Or edit ios/YourApp/YourApp.entitlements:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.developer.associated-domains</key>
  <array>
    <string>applinks:myapp.com</string>
    <string>applinks:www.myapp.com</string>
  </array>
</dict>
</plist>

Step 2: Create Apple App Site Association File

Create a file named apple-app-site-association (no extension) on your server:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAM_ID.com.yourcompany.yourapp",
        "paths": [
          "/product/*",
          "/profile/*",
          "/category/*",
          "/cart",
          "/checkout/*",
          "NOT /admin/*",
          "NOT /api/*"
        ]
      }
    ]
  },
  "webcredentials": {
    "apps": [
      "TEAM_ID.com.yourcompany.yourapp"
    ]
  }
}

Important notes:

  • Replace TEAM_ID with your Apple Team ID (found in Apple Developer Portal)
  • Replace com.yourcompany.yourapp with your bundle identifier
  • Use NOT prefix to exclude paths from universal links
  • The file must be served at https://yourdomain.com/.well-known/apple-app-site-association or https://yourdomain.com/apple-app-site-association

Step 3: Host the Association File

Upload the file to your web server at:

https://myapp.com/.well-known/apple-app-site-association

Requirements:

  • Must be served over HTTPS
  • Must have a valid SSL certificate
  • Content-Type should be application/json (though not strictly required)
  • File must be accessible without authentication
  • No redirects allowed

Example with Express.js

// server.js
const express = require('express');
const path = require('path');
const app = express();

// Serve apple-app-site-association file
app.get('/.well-known/apple-app-site-association', (req, res) => {
  res.set('Content-Type', 'application/json');
  res.sendFile(path.join(__dirname, 'public', 'apple-app-site-association'));
});

// Legacy path (still supported)
app.get('/apple-app-site-association', (req, res) => {
  res.set('Content-Type', 'application/json');
  res.sendFile(path.join(__dirname, 'public', 'apple-app-site-association'));
});

app.listen(443); // Must use HTTPS

Example with Next.js

// public/.well-known/apple-app-site-association
// (Place file in public directory)

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/.well-known/apple-app-site-association',
        headers: [
          {
            key: 'Content-Type',
            value: 'application/json',
          },
        ],
      },
    ];
  },
};

Use Apple's validator to test your setup:

  1. Visit: https://search.developer.apple.com/appsearch-validation-tool/
  2. Enter your URL: https://myapp.com
  3. Check for any errors

Or use the command line:

# Download and check the association file
curl -v https://myapp.com/.well-known/apple-app-site-association

Step 5: Update React Native Code

Your React Navigation linking configuration already handles universal links:

// src/navigation/linking.ts
const linking: LinkingOptions<RootStackParamList> = {
  prefixes: [
    'myapp://',
    'https://myapp.com',      // Universal Links
    'https://www.myapp.com',  // Universal Links
  ],
  // ... rest of config
};

In Simulator

# Test with xcrun
xcrun simctl openurl booted "https://myapp.com/product/123"

On Physical Device

You cannot tap a link within your app to test universal links (this will open in Safari). Instead:

  1. Notes App: Create a note with the link and tap it
  2. Messages: Send yourself a message with the link
  3. Mail: Email yourself the link
  4. Safari: Type the URL in Safari's address bar and tap Go (not autocomplete)

Important: After installation, you may need to restart the device for universal links to work properly.

Implementing Android App Links

Android App Links provide verified deep linking using HTTPS URLs.

Step 1: Configure Intent Filters

Update android/app/src/main/AndroidManifest.xml:

<activity
  android:name=".MainActivity"
  android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
  android:launchMode="singleTask"
  android:windowSoftInputMode="adjustResize">
  
  <!-- Existing intent filters -->
  
  <!-- App Links intent filter (autoVerify for verified links) -->
  <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="myapp.com" />
    <data
      android:scheme="https"
      android:host="www.myapp.com" />
    
    <!-- Define specific paths if needed -->
    <data android:pathPrefix="/product" />
    <data android:pathPrefix="/profile" />
    <data android:pathPrefix="/category" />
    <data android:pathPrefix="/cart" />
    <data android:pathPrefix="/checkout" />
  </intent-filter>
</activity>

Note: android:autoVerify="true" enables automatic verification of App Links.

You need to create a assetlinks.json file with your app's signing certificate fingerprint.

Get Your SHA256 Fingerprint

# For debug keystore
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android

# For release keystore
keytool -list -v -keystore /path/to/your/release.keystore -alias your-alias

Look for the SHA256 fingerprint in the output.

Create assetlinks.json

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.yourcompany.yourapp",
      "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"
      ]
    }
  }
]

Replace:

  • package_name: Your Android package name (from build.gradle)
  • sha256_cert_fingerprints: Your app's SHA256 fingerprint (from previous step)

Important: Include fingerprints for both debug and release builds if testing in development.

Upload to your server at:

https://myapp.com/.well-known/assetlinks.json

Requirements:

  • Must be served over HTTPS with valid certificate
  • Content-Type must be application/json
  • Must be accessible without authentication or redirects

Example with Express.js

// server.js
app.get('/.well-known/assetlinks.json', (req, res) => {
  res.set('Content-Type', 'application/json');
  res.sendFile(path.join(__dirname, 'public', 'assetlinks.json'));
});

Example with Next.js

// Place file at: public/.well-known/assetlinks.json

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/.well-known/assetlinks.json',
        headers: [
          {
            key: 'Content-Type',
            value: 'application/json',
          },
        ],
      },
    ];
  },
};

Test your configuration:

# Check if the file is accessible
curl https://myapp.com/.well-known/assetlinks.json

# Use Google's verification tool
# Visit: https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://myapp.com&relation=delegate_permission/common.handle_all_urls

Step 5: Test on Android Device

Using ADB

# Test App Link
adb shell am start -W -a android.intent.action.VIEW -d "https://myapp.com/product/123" com.yourcompany.yourapp

# Check verification status
adb shell pm get-app-links com.yourcompany.yourapp

# Manually verify domain
adb shell pm verify-app-links --re-verify com.yourcompany.yourapp

Check Domain Verification

# View domain verification status
adb shell pm get-app-links com.yourcompany.yourapp

Expected output:

com.yourcompany.yourapp:
  ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  Signatures: [...]
  Domain verification state:
    myapp.com: verified
    www.myapp.com: verified

Reset Domain Verification (for testing)

# Reset verification state
adb shell pm set-app-links-user-selection --user cur --package com.yourcompany.yourapp true myapp.com www.myapp.com

Advanced Deep Linking Patterns

Deferred deep links allow you to store the link data and navigate to the correct screen after the user installs the app and opens it for the first time.

// src/services/deferredDeepLink.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Linking } from 'react-native';

const DEFERRED_DEEP_LINK_KEY = '@deferred_deep_link';

export const DeferredDeepLinkService = {
  // Store deep link for later use (called from landing page before app install)
  async storeDeepLink(url: string): Promise<void> {
    try {
      await AsyncStorage.setItem(DEFERRED_DEEP_LINK_KEY, url);
    } catch (error) {
      console.error('Error storing deferred deep link:', error);
    }
  },

  // Retrieve and clear stored deep link
  async getAndClearDeepLink(): Promise<string | null> {
    try {
      const url = await AsyncStorage.getItem(DEFERRED_DEEP_LINK_KEY);
      if (url) {
        await AsyncStorage.removeItem(DEFERRED_DEEP_LINK_KEY);
        return url;
      }
      return null;
    } catch (error) {
      console.error('Error retrieving deferred deep link:', error);
      return null;
    }
  },

  // Handle on first app launch
  async handleDeferredDeepLink(): Promise<string | null> {
    // Check if this is first launch
    const hasLaunchedBefore = await AsyncStorage.getItem('@has_launched');
    
    if (!hasLaunchedBefore) {
      await AsyncStorage.setItem('@has_launched', 'true');
      
      // Get deferred deep link if exists
      const deferredUrl = await this.getAndClearDeepLink();
      if (deferredUrl) {
        return deferredUrl;
      }
    }
    
    // Check for initial URL (direct deep link)
    const initialUrl = await Linking.getInitialURL();
    return initialUrl;
  },
};

Using in your app:

// App.tsx
import React, { useEffect, useState } from 'react';
import { DeferredDeepLinkService } from './services/deferredDeepLink';
import AppNavigator from './navigation/AppNavigator';

export default function App() {
  const [isReady, setIsReady] = useState(false);
  const [initialUrl, setInitialUrl] = useState<string | null>(null);

  useEffect(() => {
    async function prepare() {
      try {
        // Handle deferred deep link on first launch
        const url = await DeferredDeepLinkService.handleDeferredDeepLink();
        if (url) {
          setInitialUrl(url);
        }
      } catch (error) {
        console.error('Error handling deferred deep link:', error);
      } finally {
        setIsReady(true);
      }
    }

    prepare();
  }, []);

  if (!isReady) {
    return null; // Or your splash screen
  }

  return <AppNavigator initialUrl={initialUrl} />;
}

Protect routes that require authentication:

// src/hooks/useProtectedDeepLink.ts
import { useEffect } from 'react';
import { Linking } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import AsyncStorage from '@react-native-async-storage/async-storage';

export function useProtectedDeepLink(isAuthenticated: boolean) {
  const navigation = useNavigation();

  useEffect(() => {
    const handleProtectedLink = async (url: string) => {
      const { path } = Linking.parse(url);

      // Define protected routes
      const protectedRoutes = ['/checkout', '/settings', '/profile'];
      const isProtectedRoute = protectedRoutes.some(route => 
        path?.startsWith(route)
      );

      if (isProtectedRoute && !isAuthenticated) {
        // Store the intended destination
        await AsyncStorage.setItem('@pending_deep_link', url);
        
        // Navigate to login
        navigation.navigate('Login', { 
          returnUrl: url 
        });
      } else {
        // Handle normally
        // React Navigation will handle this automatically
      }
    };

    // Check initial URL
    Linking.getInitialURL().then(url => {
      if (url) handleProtectedLink(url);
    });

    // Listen for URLs while app is open
    const subscription = Linking.addEventListener('url', ({ url }) => {
      handleProtectedLink(url);
    });

    return () => subscription.remove();
  }, [isAuthenticated]);
}

After login, navigate to pending deep link:

// src/screens/LoginScreen.tsx
import AsyncStorage from '@react-native-async-storage/async-storage';

async function handleLoginSuccess() {
  // Check for pending deep link
  const pendingUrl = await AsyncStorage.getItem('@pending_deep_link');
  
  if (pendingUrl) {
    await AsyncStorage.removeItem('@pending_deep_link');
    // Navigation will be handled automatically by React Navigation
    Linking.openURL(pendingUrl);
  } else {
    navigation.navigate('Home');
  }
}

Handle dynamic parameters and query strings:

// src/utils/deepLinkParser.ts
import { Linking } from 'react-native';

export interface DeepLinkParams {
  path: string;
  params: Record<string, string>;
  queryParams: Record<string, string>;
}

export function parseDeepLink(url: string): DeepLinkParams {
  const { hostname, path, queryParams } = Linking.parse(url);

  // Extract path parameters
  const pathParts = path?.split('/').filter(Boolean) || [];
  const params: Record<string, string> = {};

  // Example: /product/123 -> { productId: '123' }
  if (pathParts[0] === 'product' && pathParts[1]) {
    params.productId = pathParts[1];
  } else if (pathParts[0] === 'category' && pathParts[1]) {
    params.categoryId = pathParts[1];
  }

  return {
    path: path || '',
    params,
    queryParams: queryParams || {},
  };
}

// Usage example
const { path, params, queryParams } = parseDeepLink('https://myapp.com/product/123?ref=email&utm_source=campaign');
// path: 'product/123'
// params: { productId: '123' }
// queryParams: { ref: 'email', utm_source: 'campaign' }

Analytics Tracking

Track deep link opens for analytics:

// src/services/analyticsService.ts
export const AnalyticsService = {
  trackDeepLinkOpen(url: string, source: 'initial' | 'active') {
    const { path, queryParams } = Linking.parse(url);

    // Track with your analytics service (e.g., Firebase, Amplitude)
    analytics.track('deep_link_opened', {
      url,
      path,
      source,
      utm_source: queryParams?.utm_source,
      utm_medium: queryParams?.utm_medium,
      utm_campaign: queryParams?.utm_campaign,
      timestamp: new Date().toISOString(),
    });

    console.log('Deep link tracked:', { url, source });
  },
};

// Use in your deep link handler
const handleDeepLink = (url: string, source: 'initial' | 'active') => {
  AnalyticsService.trackDeepLinkOpen(url, source);
  // ... rest of handling logic
};

Testing Deep Links

Testing URL Schemes

iOS Simulator

xcrun simctl openurl booted "myapp://product/123"
xcrun simctl openurl booted "myapp://profile/john-doe?ref=share"

Android Emulator/Device

adb shell am start -W -a android.intent.action.VIEW -d "myapp://product/123" com.yourcompany.yourapp

Simulator

xcrun simctl openurl booted "https://myapp.com/product/123"

Physical Device

Create test links in:

  • Notes app
  • Messages
  • Email
  • Safari (type in address bar, don't use autocomplete)

Important: Links clicked within your app will open in Safari, not your app. This is expected iOS behavior.

# Test the link
adb shell am start -W -a android.intent.action.VIEW -d "https://myapp.com/product/123"

# Verify domain status
adb shell pm get-app-links com.yourcompany.yourapp

# Force verification
adb shell pm verify-app-links --re-verify com.yourcompany.yourapp

Automated Testing with Detox

// e2e/deepLinking.test.js
describe('Deep Linking', () => {
  beforeAll(async () => {
    await device.launchApp();
  });

  it('should navigate to product details via deep link', async () => {
    // Launch with deep link
    await device.launchApp({
      newInstance: true,
      url: 'myapp://product/123',
    });

    // Verify navigation
    await expect(element(by.id('product-details-screen'))).toBeVisible();
    await expect(element(by.text('Product 123'))).toBeVisible();
  });

  it('should handle universal link', async () => {
    await device.launchApp({
      newInstance: true,
      url: 'https://myapp.com/profile/john-doe',
    });

    await expect(element(by.id('profile-screen'))).toBeVisible();
    await expect(element(by.text('john-doe'))).toBeVisible();
  });

  it('should handle invalid deep link gracefully', async () => {
    await device.launchApp({
      newInstance: true,
      url: 'myapp://invalid/path',
    });

    // Should show home or not found screen
    await expect(element(by.id('home-screen'))).toBeVisible();
  });
});

If using a third-party service:

// Example with Firebase Dynamic Links
import dynamicLinks from '@react-native-firebase/dynamic-links';

function App() {
  useEffect(() => {
    const unsubscribe = dynamicLinks().onLink(handleDynamicLink);
    
    // Check initial link
    dynamicLinks()
      .getInitialLink()
      .then(link => {
        if (link) handleDynamicLink(link);
      });

    return () => unsubscribe();
  }, []);

  const handleDynamicLink = (link: FirebaseDynamicLinksTypes.DynamicLink) => {
    // Handle the link
    console.log('Dynamic link received:', link.url);
  };

  return <AppNavigator />;
}

Security Best Practices

Always validate and sanitize data from deep links:

// src/utils/deepLinkValidator.ts
export class DeepLinkValidator {
  // Validate product ID
  static isValidProductId(id: string): boolean {
    // Only allow alphanumeric and hyphens
    return /^[a-zA-Z0-9-]+$/.test(id) && id.length <= 50;
  }

  // Validate username
  static isValidUsername(username: string): boolean {
    return /^[a-zA-Z0-9_-]+$/.test(username) && 
           username.length >= 3 && 
           username.length <= 30;
  }

  // Validate URL to prevent open redirect
  static isValidRedirectUrl(url: string): boolean {
    try {
      const parsed = new URL(url);
      const allowedDomains = ['myapp.com', 'www.myapp.com'];
      return allowedDomains.includes(parsed.hostname);
    } catch {
      return false;
    }
  }

  // Sanitize input
  static sanitize(input: string): string {
    return input
      .trim()
      .replace(/[<>]/g, '') // Remove potential HTML
      .substring(0, 100); // Limit length
  }
}

// Usage
const handleDeepLink = (url: string) => {
  const { params } = parseDeepLink(url);

  if (params.productId && DeepLinkValidator.isValidProductId(params.productId)) {
    navigation.navigate('ProductDetails', { 
      productId: DeepLinkValidator.sanitize(params.productId) 
    });
  } else {
    console.warn('Invalid product ID in deep link');
    navigation.navigate('Home');
  }
};

iOS Protection

Universal Links inherently prevent hijacking through domain verification. Make sure:

  • Your apple-app-site-association file is properly configured
  • You're using HTTPS with a valid certificate
  • The file is served without redirects

Android Protection

Use verified App Links with android:autoVerify="true":

<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="myapp.com" />
</intent-filter>

Handle Sensitive Operations Carefully

Never perform sensitive operations directly from deep links:

// ❌ BAD - Dangerous!
const handleDeepLink = (url: string) => {
  if (url.includes('/delete-account')) {
    deleteUserAccount(); // Never do this!
  }
};

// ✅ GOOD - Require confirmation
const handleDeepLink = (url: string) => {
  if (url.includes('/delete-account')) {
    navigation.navigate('DeleteAccountConfirmation', {
      requiresAuth: true,
      requiresConfirmation: true,
    });
  }
};

Rate Limiting

Prevent abuse by rate limiting deep link handling:

// src/utils/rateLimiter.ts
class RateLimiter {
  private timestamps: number[] = [];
  private readonly maxRequests: number;
  private readonly timeWindow: number;

  constructor(maxRequests: number, timeWindowMs: number) {
    this.maxRequests = maxRequests;
    this.timeWindow = timeWindowMs;
  }

  isAllowed(): boolean {
    const now = Date.now();
    // Remove timestamps outside the time window
    this.timestamps = this.timestamps.filter(
      timestamp => now - timestamp < this.timeWindow
    );

    if (this.timestamps.length < this.maxRequests) {
      this.timestamps.push(now);
      return true;
    }

    return false;
  }
}

// Allow max 5 deep links per 10 seconds
const deepLinkLimiter = new RateLimiter(5, 10000);

const handleDeepLink = (url: string) => {
  if (!deepLinkLimiter.isAllowed()) {
    console.warn('Deep link rate limit exceeded');
    return;
  }

  // Process deep link
};

Troubleshooting Common Issues

Solutions:

  1. Check that apple-app-site-association file is properly hosted
  2. Verify the file is accessible: curl https://yourdomain.com/.well-known/apple-app-site-association
  3. Ensure TEAM_ID and bundle identifier are correct
  4. Try uninstalling and reinstalling the app
  5. Restart the device (iOS caches association files)
  6. Don't tap links within your own app (this will always open Safari)

Issue: Association file not loading

Solutions:

  1. Ensure HTTPS with valid SSL certificate
  2. Check Content-Type is application/json
  3. Verify no redirects (301, 302) are happening
  4. Make sure file is at root or .well-known directory
  5. Test with Apple's validator: https://search.developer.apple.com/appsearch-validation-tool/

Issue: Paths not matching

Solutions:

  1. Check path patterns in association file
  2. Use wildcards correctly: /product/* matches /product/123
  3. Verify NOT patterns aren't excluding your paths
  4. Test specific URLs with simulator: xcrun simctl openurl booted "https://..."

Issue: Domain not verified

Solutions:

# Check verification status
adb shell pm get-app-links com.yourcompany.yourapp

# Manually verify
adb shell pm verify-app-links --re-verify com.yourcompany.yourapp

# Reset and test
adb shell pm set-app-links --package com.yourcompany.yourapp 0

Issue: SHA256 fingerprint mismatch

Solutions:

  1. Verify you're using the correct keystore:
keytool -list -v -keystore /path/to/keystore -alias your-alias
  1. Include both debug and release fingerprints in assetlinks.json
  2. Ensure no spaces in the fingerprint string
  3. Use uppercase letters with colons as separators

Issue: Intent filter not working

Solutions:

  1. Ensure android:autoVerify="true" is set
  2. Check android:launchMode="singleTask" in AndroidManifest.xml
  3. Verify all required categories are present
  4. Test with ADB:
adb shell am start -W -a android.intent.action.VIEW -d "https://myapp.com/test"

General Deep Linking Issues

Check:

  1. Navigation is set up before handling links
  2. Screen names match exactly in linking config
  3. Parameters are being parsed correctly
  4. No navigation blockers (modals, alerts)
// Add logging to debug
const linking: LinkingOptions<RootStackParamList> = {
  prefixes: ['myapp://', 'https://myapp.com'],
  config: {
    // ... config
  },
  // Add these for debugging
  getStateFromPath: (path, config) => {
    console.log('Deep link path:', path);
    const state = getStateFromPath(path, config);
    console.log('Navigation state:', state);
    return state;
  },
};

Solutions:

  1. Add error boundaries around navigation
  2. Validate all parameters before using
  3. Handle missing data gracefully
  4. Check for null/undefined values
const handleDeepLink = async (url: string) => {
  try {
    const { path, params } = parseDeepLink(url);
    
    // Validate before navigating
    if (!path) {
      console.warn('No path in deep link');
      return;
    }

    // Navigate safely
    navigation.navigate(screenName, params);
  } catch (error) {
    console.error('Error handling deep link:', error);
    // Fallback to home
    navigation.navigate('Home');
  }
};

Issue: Query parameters not working

Solutions:

// Ensure parse function is defined
Category: {
  path: 'category/:categoryId',
  parse: {
    categoryId: (id: string) => id,
    sort: (sort: string) => sort, // Parse query params too!
  },
  stringify: {
    categoryId: (id: string) => id,
    sort: (sort: string) => sort,
  },
}

Debugging Tips

Enable Debug Logging

// Add to App.tsx
if (__DEV__) {
  // Log all linking events
  Linking.addEventListener('url', ({ url }) => {
    console.log('🔗 Deep link received:', url);
  });

  // Log navigation state changes
  const navigationRef = React.useRef();

  return (
    <NavigationContainer
      ref={navigationRef}
      onStateChange={(state) => {
        console.log('📍 Navigation state:', state);
      }}
      linking={linking}
    >
      {/* ... */}
    </NavigationContainer>
  );
}

Test in Production

Before releasing, test with:

  1. Fresh install (not upgrade)
  2. Multiple link types (URL scheme, universal link, app link)
  3. Different entry points (cold start, warm start, background)
  4. Various devices and OS versions
  5. Real-world scenarios (email, SMS, social media)

Production Checklist

Before deploying your app with deep linking:

iOS Checklist

  • [ ] Associated Domains capability added in Xcode
  • [ ] Correct Team ID in apple-app-site-association file
  • [ ] Correct bundle identifier in association file
  • [ ] Association file hosted at /.well-known/apple-app-site-association
  • [ ] HTTPS with valid SSL certificate
  • [ ] File returns correct Content-Type (application/json)
  • [ ] Tested with Apple's validation tool
  • [ ] Tested on physical device (not just simulator)
  • [ ] Tested after fresh install
  • [ ] All paths properly configured with wildcards
  • [ ] Excluded admin/api paths with NOT prefix

Android Checklist

  • [ ] Intent filter with android:autoVerify="true"
  • [ ] assetlinks.json file created with correct fingerprints
  • [ ] Both debug and release fingerprints included (for testing)
  • [ ] File hosted at /.well-known/assetlinks.json
  • [ ] Correct package name in assetlinks file
  • [ ] Domain verification successful (checked with ADB)
  • [ ] Tested with adb shell pm get-app-links
  • [ ] android:launchMode="singleTask" set
  • [ ] Tested on multiple Android versions
  • [ ] Tested after fresh install

General Checklist

  • [ ] All deep link paths tested
  • [ ] Authentication-required paths protected
  • [ ] Input validation on all parameters
  • [ ] Error handling for invalid links
  • [ ] Analytics tracking implemented
  • [ ] Deferred deep links tested (if applicable)
  • [ ] Rate limiting implemented
  • [ ] Fallback behavior tested (web fallback)
  • [ ] Testing on various entry scenarios
  • [ ] Documentation for QA team
  • [ ] Monitoring and alerting set up

Conclusion

Deep linking and universal links are essential features for modern mobile applications. By following this guide, you've learned how to:

  • Implement basic URL scheme deep linking
  • Configure iOS Universal Links with proper domain verification
  • Set up Android App Links with digital asset verification
  • Handle complex navigation scenarios with React Navigation
  • Implement security best practices
  • Debug and troubleshoot common issues

Key takeaways:

  1. Always use Universal Links/App Links in production instead of just URL schemes for better security and user experience
  2. Validate and sanitize all deep link data to prevent security vulnerabilities
  3. Test thoroughly on real devices, not just simulators/emulators
  4. Handle errors gracefully with fallback navigation
  5. Track analytics to understand user behavior and campaign effectiveness

Deep linking significantly improves user engagement by providing seamless transitions from web to app. Whether you're implementing marketing campaigns, social sharing, or password reset flows, a well-implemented deep linking system is crucial for your app's success.

Remember to test your deep links extensively before releasing to production, and monitor their performance using analytics to continuously improve the user experience.

Happy coding! 🚀