Introduction

Deep linking enables URLs to open specific content within your mobile app rather than a web page. When a user taps a link to your product page, they land directly in your app’s product screen, authenticated and ready to buy.

This capability transforms mobile marketing. Email campaigns, social media posts, and QR codes all become direct pathways into your app. Without deep linking, users land on your website and must find and open your app manually, losing most of them along the way.

This guide covers implementing deep linking on both iOS and Android, including the more reliable Universal Links and App Links that work even when tapped from email or messages where regular URL schemes fail.

The original deep linking approach using custom URL schemes:

yourapp://product/123
yourapp://user/profile
myapp://checkout?cart=abc123

Advantages:

  • Simple to implement
  • Works across all platforms
  • No server configuration needed

Limitations:

  • Not unique (any app can claim any scheme)
  • Fails in many contexts (email clients, Safari)
  • No fallback if app not installed
  • Security concerns (no verification)

Modern deep linking using verified HTTPS URLs:

https://yourapp.com/product/123
https://yourapp.com/user/profile

Advantages:

  • Verified ownership (only you can claim your domain)
  • Works everywhere including email and messages
  • Graceful fallback to website
  • More secure

Limitations:

  • Requires server configuration
  • More complex setup
  • Domain verification required

Handle links when the app is not yet installed:

  1. User taps link
  2. User is sent to App Store
  3. User installs and opens app
  4. App retrieves original link destination
  5. User sees intended content

Requires third-party services like Branch, Adjust, or Firebase Dynamic Links.

Step 1:

Implementing Universal Links (iOS) Infographic Create Apple App Site Association File

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

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appIDs": ["TEAM_ID.com.yourcompany.yourapp"],
        "paths": ["/product/*", "/user/*", "/checkout/*"],
        "components": [
          {
            "/": "/product/*",
            "comment": "Product pages"
          },
          {
            "/": "/user/*",
            "comment": "User profiles"
          },
          {
            "/": "/checkout/*",
            "comment": "Checkout flow"
          },
          {
            "/": "/*",
            "exclude": true,
            "comment": "Exclude all other paths"
          }
        ]
      }
    ]
  }
}

Step 2: Host the AASA File

The file must be accessible at:

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

Requirements:

  • Served over HTTPS with valid certificate
  • Content-Type: application/json
  • No redirects
  • Accessible without authentication

Nginx configuration:

location /.well-known/apple-app-site-association {
    default_type application/json;
    add_header Cache-Control "max-age=3600";
}

Step 3: Enable Associated Domains in Xcode

  1. Open your project in Xcode
  2. Select your target
  3. Go to Signing & Capabilities
  4. Click + Capability and add Associated Domains
  5. Add your domain: applinks:yourdomain.com

For React Native with React Navigation:

// App.tsx
import { NavigationContainer } from '@react-navigation/native';
import { Linking } from 'react-native';

const linking = {
  prefixes: ['https://yourdomain.com', 'yourapp://'],
  config: {
    screens: {
      Home: '',
      Product: 'product/:id',
      UserProfile: 'user/:username',
      Checkout: 'checkout',
      Settings: 'settings'
    }
  }
};

function App() {
  return (
    <NavigationContainer linking={linking} fallback={<LoadingScreen />}>
      <AppNavigator />
    </NavigationContainer>
  );
}

For native iOS (AppDelegate.swift):

// AppDelegate.swift
import UIKit

class AppDelegate: UIResponder, UIApplicationDelegate {

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

        // Handle the URL
        return handleDeepLink(url: url)
    }

    private func handleDeepLink(url: URL) -> Bool {
        let pathComponents = url.pathComponents

        if pathComponents.count >= 2 {
            switch pathComponents[1] {
            case "product":
                if pathComponents.count >= 3 {
                    let productId = pathComponents[2]
                    navigateToProduct(id: productId)
                    return true
                }
            case "user":
                if pathComponents.count >= 3 {
                    let username = pathComponents[2]
                    navigateToUserProfile(username: username)
                    return true
                }
            default:
                break
            }
        }

        return false
    }
}

Create assetlinks.json on your server:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.yourcompany.yourapp",
      "sha256_cert_fingerprints": [
        "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
      ]
    }
  }
]

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 your-release-key.keystore -alias your-alias

The file must be at:

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

Step 3: Configure Android Manifest

{/* android/app/src/main/AndroidManifest.xml */}
<manifest>
    <application>
        <activity
            android:name=".MainActivity"
            android:launchMode="singleTask">

            {/* App 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="yourdomain.com"
                    android:pathPrefix="/product" />
                <data
                    android:scheme="https"
                    android:host="yourdomain.com"
                    android:pathPrefix="/user" />
            </intent-filter>

            {/* 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="yourapp" />
            </intent-filter>
        </activity>
    </application>
</manifest>

For React Native, the linking configuration handles this automatically. For native Android:

// MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handleIntent(intent)
    }

    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        intent?.let { handleIntent(it) }
    }

    private fun handleIntent(intent: Intent) {
        val action = intent.action
        val data = intent.data

        if (Intent.ACTION_VIEW == action && data != null) {
            handleDeepLink(data)
        }
    }

    private fun handleDeepLink(uri: Uri) {
        val pathSegments = uri.pathSegments

        when {
            pathSegments.size >= 2 && pathSegments[0] == "product" -> {
                val productId = pathSegments[1]
                navigateToProduct(productId)
            }
            pathSegments.size >= 2 && pathSegments[0] == "user" -> {
                val username = pathSegments[1]
                navigateToUserProfile(username)
            }
        }
    }
}

React Native Deep Linking Setup

Complete Linking Configuration

// navigation/linking.ts
import { LinkingOptions } from '@react-navigation/native';
import { Linking } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

export const linking: LinkingOptions<RootStackParamList> = {
  prefixes: [
    'https://yourdomain.com',
    'https://www.yourdomain.com',
    'yourapp://'
  ],

  config: {
    screens: {
      // Tab Navigator
      MainTabs: {
        screens: {
          HomeTab: {
            screens: {
              Home: '',
              ProductList: 'products',
              ProductDetail: 'product/:id'
            }
          },
          SearchTab: {
            screens: {
              Search: 'search',
              SearchResults: 'search/:query'
            }
          },
          ProfileTab: {
            screens: {
              Profile: 'profile',
              Settings: 'settings'
            }
          }
        }
      },
      // Modal screens
      Checkout: 'checkout',
      Auth: 'auth',
      // Deep-linked screens
      UserProfile: 'user/:username',
      Order: 'order/:orderId',
      // Catch-all
      NotFound: '*'
    }
  },

  async getInitialURL() {
    // Check for deferred deep link
    const savedDeepLink = await AsyncStorage.getItem('deferred_deep_link');
    if (savedDeepLink) {
      await AsyncStorage.removeItem('deferred_deep_link');
      return savedDeepLink;
    }

    // Check for initial URL
    const url = await Linking.getInitialURL();
    return url;
  },

  subscribe(listener) {
    const subscription = Linking.addEventListener('url', ({ url }) => {
      listener(url);
    });

    return () => {
      subscription.remove();
    };
  }
};

Handling Authentication

Often deep links require authentication:

// navigation/DeepLinkHandler.tsx
import React, { useEffect, useState } from 'react';
import { Linking } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
import { useAuth } from '../hooks/useAuth';

export function DeepLinkHandler({ children }) {
  const { isAuthenticated, isLoading } = useAuth();
  const navigation = useNavigation();
  const [pendingDeepLink, setPendingDeepLink] = useState<string | null>(null);

  useEffect(() => {
    const handleDeepLink = async (url: string) => {
      // Parse the URL
      const parsed = parseDeepLink(url);

      // Check if authentication is required
      if (requiresAuth(parsed.path) && !isAuthenticated) {
        // Save deep link for after authentication
        await AsyncStorage.setItem('pending_deep_link', url);
        navigation.navigate('Auth', { returnUrl: url });
        return;
      }

      // Navigate directly
      navigateToDeepLink(navigation, parsed);
    };

    const subscription = Linking.addEventListener('url', ({ url }) => {
      handleDeepLink(url);
    });

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

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

  // After authentication, check for pending deep link
  useEffect(() => {
    if (isAuthenticated && !isLoading) {
      AsyncStorage.getItem('pending_deep_link').then((url) => {
        if (url) {
          AsyncStorage.removeItem('pending_deep_link');
          const parsed = parseDeepLink(url);
          navigateToDeepLink(navigation, parsed);
        }
      });
    }
  }, [isAuthenticated, isLoading]);

  return <>{children}</>;
}

function requiresAuth(path: string): boolean {
  const protectedPaths = ['/checkout', '/order', '/profile/settings'];
  return protectedPaths.some((p) => path.startsWith(p));
}

function parseDeepLink(url: string) {
  const parsed = new URL(url);
  return {
    path: parsed.pathname,
    params: Object.fromEntries(parsed.searchParams),
    hash: parsed.hash
  };
}

function navigateToDeepLink(navigation, { path, params }) {
  const segments = path.split('/').filter(Boolean);

  switch (segments[0]) {
    case 'product':
      navigation.navigate('ProductDetail', { id: segments[1], ...params });
      break;
    case 'user':
      navigation.navigate('UserProfile', { username: segments[1] });
      break;
    case 'order':
      navigation.navigate('Order', { orderId: segments[1] });
      break;
    default:
      navigation.navigate('Home');
  }
}

Deferred Deep Linking

For users who do not have the app installed:

// services/dynamicLinks.ts
import dynamicLinks from '@react-native-firebase/dynamic-links';

async function createDynamicLink(path: string): Promise<string> {
  const link = await dynamicLinks().buildShortLink({
    link: `https://yourdomain.com${path}`,
    domainUriPrefix: 'https://yourapp.page.link',
    ios: {
      bundleId: 'com.yourcompany.yourapp',
      appStoreId: '1234567890',
      fallbackUrl: `https://yourdomain.com${path}`
    },
    android: {
      packageName: 'com.yourcompany.yourapp',
      fallbackUrl: `https://yourdomain.com${path}`
    },
    social: {
      title: 'Check this out',
      descriptionText: 'Open in our app',
      imageUrl: 'https://yourdomain.com/og-image.png'
    }
  });

  return link;
}

// Handle incoming dynamic links
function useDynamicLinks() {
  const navigation = useNavigation();

  useEffect(() => {
    // Handle link when app is opened from terminated state
    dynamicLinks()
      .getInitialLink()
      .then((link) => {
        if (link) {
          handleDeepLink(link.url);
        }
      });

    // Handle link when app is in background
    const unsubscribe = dynamicLinks().onLink((link) => {
      handleDeepLink(link.url);
    });

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

Option 2: Branch.io

// services/branch.ts
import branch from 'react-native-branch';

// Initialize Branch
branch.subscribe(({ error, params, uri }) => {
  if (error) {
    console.error('Branch error:', error);
    return;
  }

  if (params['+clicked_branch_link']) {
    // Handle deep link params
    const productId = params.productId;
    const userId = params.userId;

    if (productId) {
      navigation.navigate('ProductDetail', { id: productId });
    } else if (userId) {
      navigation.navigate('UserProfile', { id: userId });
    }
  }
});

// Create a Branch link
async function createBranchLink(data: object): Promise<string> {
  const branchUniversalObject = await branch.createBranchUniversalObject(
    'product/123',
    {
      title: 'Product Name',
      contentDescription: 'Check out this product',
      contentImageUrl: 'https://example.com/product.jpg',
      contentMetadata: {
        customMetadata: data
      }
    }
  );

  const { url } = await branchUniversalObject.generateShortUrl({
    feature: 'sharing',
    channel: 'app'
  });

  return url;
}

Testing on iOS Simulator

# URI scheme
xcrun simctl openurl booted "yourapp://product/123"

# Universal Links (requires built app)
xcrun simctl openurl booted "https://yourdomain.com/product/123"

Testing on Android Emulator

# URI scheme
adb shell am start -a android.intent.action.VIEW -d "yourapp://product/123"

# App Links
adb shell am start -a android.intent.action.VIEW -d "https://yourdomain.com/product/123"

Verifying Server Configuration

# Check Apple App Site Association
curl -I https://yourdomain.com/.well-known/apple-app-site-association

# Validate AASA content
curl https://yourdomain.com/.well-known/apple-app-site-association | jq .

# Check Android Asset Links
curl https://yourdomain.com/.well-known/assetlinks.json | jq .

# Use Google's verification tool
# https://developers.google.com/digital-asset-links/tools/generator

Debug Mode

// Add debug logging
useEffect(() => {
  if (__DEV__) {
    Linking.addEventListener('url', ({ url }) => {
      console.log('Deep link received:', url);
    });

    Linking.getInitialURL().then((url) => {
      console.log('Initial URL:', url);
    });
  }
}, []);

Analytics and Attribution

Track deep link performance:

// analytics/deepLinkTracking.ts
import analytics from '@react-native-firebase/analytics';

export async function trackDeepLink(url: string, source: string) {
  const parsed = new URL(url);

  await analytics().logEvent('deep_link_opened', {
    path: parsed.pathname,
    source,
    campaign: parsed.searchParams.get('utm_campaign'),
    medium: parsed.searchParams.get('utm_medium'),
    content: parsed.searchParams.get('utm_content')
  });
}

// Use in deep link handler
function handleDeepLink(url: string, source: 'universal_link' | 'uri_scheme' | 'deferred') {
  trackDeepLink(url, source);

  // Continue with navigation...
}

Common Issues and Solutions

  1. AASA file not accessible: Check HTTPS, no redirects, correct Content-Type
  2. App not installed from App Store: Universal Links require the app to be downloaded from App Store (TestFlight counts)
  3. Domain not in Associated Domains: Verify Xcode capability configuration
  4. Apple CDN caching: AASA is cached; updates can take up to 24 hours
  1. Fingerprint mismatch: Ensure correct SHA256 in assetlinks.json
  2. Multiple signing keys: Include fingerprints for all keystores (debug, release, Play App Signing)
  3. autoVerify not set: Check intent-filter in AndroidManifest.xml

This is expected behaviour in some contexts:

  • Links typed directly in Safari address bar
  • Links from JavaScript redirects
  • Users who previously chose “Open in Safari”

Workaround: Use a smart banner or interstitial page that offers to open the app.

Best Practices

  1. Always provide web fallback: Users without the app should see content on your website
  2. Handle missing routes: Display a friendly “not found” screen rather than crashing
  3. Preserve state: Save pending deep links during authentication
  4. Test all paths: Automated tests for every deep-linkable route
  5. Monitor link verification: Set up alerts for AASA/assetlinks.json availability
  6. Use UTM parameters: Track marketing campaign effectiveness
  7. Short links for sharing: Long URLs with query params are ugly in messages

Conclusion

Deep linking is essential infrastructure for mobile app growth. Without it, every marketing link requires users to manually find content in your app, and most will not bother.

Start with Universal Links and App Links for your primary marketing URLs. Add URI scheme support as a fallback for edge cases. Consider deferred deep linking if user acquisition is a priority and you need to maintain context through the install process.

The setup is moderately complex, especially the server-side configuration. But once working, deep links enable marketing, sharing, and re-engagement patterns that are impossible without them.

Test thoroughly across devices, OS versions, and link contexts. Deep linking has many edge cases that only appear in specific scenarios. Automated testing catches regressions before they impact users.


Need help implementing deep linking for your mobile app? Our team has set up deep linking for apps handling millions of users. Contact us to discuss your requirements.