Deep Linking Guide

Complete guide for implementing deep links and universal links in your Expo app.


Table of Contents

  1. Overview
  2. URL Scheme Setup
  3. Universal Links (iOS)
  4. App Links (Android)
  5. Handling Links
  6. Testing
  7. Common Patterns

Overview

TypeFormatUse Case
URL Schememyapp://pathApp-to-app communication
Universal Links (iOS)https://myapp.com/pathWeb fallback, secure
App Links (Android)https://myapp.com/pathWeb 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"
    }
  }
}
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 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

  1. Go to Apple Developer Portal
  2. Membership → Team ID

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

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]);
};
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: {}
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',
    });
  }
};
// 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

# 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
# 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

// 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';
}
// 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
// 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';
  }
}
// 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
// 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

  1. Check scheme is correct in app.json
  2. Rebuild the app after changing scheme
  3. Check URL format matches expected pattern
  4. Universal links: Verify AASA file is accessible
  5. App links: Verify assetlinks.json and SHA256
  1. Long-press the link and select "Open in App"
  2. Check AASA file format and accessibility
  3. Ensure paths match exactly
  4. Wait for iOS to re-fetch AASA (can take time)
  1. Check assetlinks.json format
  2. Verify SHA256 fingerprint matches
  3. Ensure autoVerify: true is set
  4. Check for redirects on the JSON file URL

Resources