Skip to main content

Module 6: Navigation with Expo Router

Deep Linking

Connect your app to the web with URLs and universal links

🎯 Learning Objectives

  • Understand deep linking concepts and benefits
  • Configure URL schemes for your app
  • Set up universal links (iOS) and app links (Android)
  • Handle incoming deep links in Expo Router
  • Navigate to specific screens from external URLs
  • Integrate deep linking with push notifications
  • Test deep links during development

Deep Linking Overview

Deep linking allows external sourcesβ€”websites, emails, other apps, push notificationsβ€”to navigate directly to specific content within your app. Instead of just opening your app's home screen, deep links take users exactly where they need to go.

Why Deep Linking Matters

πŸ”— Deep Linking Benefits

Use Case Example
Marketing Campaigns Email links to specific products or promotions
Push Notifications Tap notification to view a specific message or order
Social Sharing Share a post that opens directly in the app
QR Codes Scan to open a specific screen or trigger an action
Web-to-App Handoff Continue browsing from website to app seamlessly

Types of Deep Links

flowchart LR
    subgraph Types["Deep Link Types"]
        A["URL Scheme
myapp://"] B["Universal Links
https://myapp.com"] C["Deferred Links
Install first, then route"] end A --> D["Opens app directly
No fallback"] B --> E["Opens app or website
Verified ownership"] C --> F["Works even if
app not installed"] style A fill:#e3f2fd style B fill:#e8f5e9 style C fill:#fff3cd
Deep Link Flow External Source Email, Web, Push myapp://product/123 or https://myapp.com/product/123 OS Link Handler iOS / Android Your App Screen Product #123

URL Schemes

URL schemes (also called custom schemes or app schemes) are the simplest form of deep linking. They use a custom protocol like myapp:// instead of https://.

Configuring URL Scheme in Expo

// app.json
{
  "expo": {
    "name": "MyApp",
    "slug": "my-app",
    "scheme": "myapp",
    "ios": {
      "bundleIdentifier": "com.mycompany.myapp"
    },
    "android": {
      "package": "com.mycompany.myapp"
    }
  }
}

With this configuration, your app responds to URLs like:

myapp://                        β†’ Opens app root
myapp://home                    β†’ Opens /home route
myapp://product/123             β†’ Opens /product/123 route
myapp://settings?tab=privacy    β†’ Opens /settings with query params

Testing URL Schemes

# iOS Simulator
npx uri-scheme open "myapp://product/123" --ios

# Android Emulator
npx uri-scheme open "myapp://product/123" --android

# Or use adb directly for Android
adb shell am start -W -a android.intent.action.VIEW -d "myapp://product/123"

# From terminal on macOS (opens in simulator)
xcrun simctl openurl booted "myapp://product/123"

Expo Go Development

During development with Expo Go, the URL scheme format is different:

# Development URLs (Expo Go)
exp://192.168.1.100:8081/--/product/123

# Production URLs (standalone app)
myapp://product/123
// Get the correct scheme for current environment
import * as Linking from 'expo-linking';

const prefix = Linking.createURL('/');
console.log('URL prefix:', prefix);
// Development: exp://192.168.1.100:8081/--/
// Production: myapp://

⚠️ URL Scheme Limitations

  • No fallback if app isn't installedβ€”the link just fails
  • Any app can claim the same scheme (no verification)
  • Not supported by all browsers (especially on iOS)
  • Can be blocked by email clients as "suspicious"

Deep Linking with Expo Router

Expo Router has built-in deep linking support. Your file-based routes automatically become deep link pathsβ€”no additional configuration needed for basic functionality.

Automatic Route Mapping

File Structure                    Deep Link URL
─────────────────────────────────────────────────────
app/index.tsx                  β†’ myapp://
app/about.tsx                  β†’ myapp://about
app/products/index.tsx         β†’ myapp://products
app/products/[id].tsx          β†’ myapp://products/123
app/(tabs)/home.tsx            β†’ myapp://home
app/(auth)/login.tsx           β†’ myapp://login
app/user/[userId]/posts.tsx    β†’ myapp://user/456/posts

Linking Configuration

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen name="product/[id]" />
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
    </Stack>
  );
}

// That's it! Expo Router handles the linking automatically.

Custom Linking Configuration (Advanced)

// If you need custom path mapping or additional schemes
// app/_layout.tsx
import { Stack } from 'expo-router';
import * as Linking from 'expo-linking';

// Custom linking configuration
const linking = {
  prefixes: [
    Linking.createURL('/'),
    'myapp://',
    'https://myapp.com',
    'https://www.myapp.com',
  ],
  config: {
    screens: {
      index: '',
      product: {
        path: 'product/:id',
        parse: {
          id: (id: string) => id,
        },
      },
      '(tabs)': {
        screens: {
          home: 'home',
          search: 'search',
          profile: 'profile',
        },
      },
    },
  },
};

// Note: Expo Router handles most of this automatically.
// Only use custom config for special cases.

Query Parameters

// Deep link: myapp://search?q=shoes&category=running
// app/search.tsx
import { useLocalSearchParams } from 'expo-router';

export default function SearchScreen() {
  const { q, category } = useLocalSearchParams<{
    q?: string;
    category?: string;
  }>();

  // q = "shoes"
  // category = "running"

  return (
    <View>
      <Text>Search: {q}</Text>
      <Text>Category: {category}</Text>
    </View>
  );
}

Creating Deep Links to Share

import * as Linking from 'expo-linking';
import { Share } from 'react-native';

function ShareProductButton({ productId }: { productId: string }) {
  const shareProduct = async () => {
    // Create a deep link URL
    const url = Linking.createURL(`/product/${productId}`);
    
    // For production, you might want to use your website URL
    const webUrl = `https://myapp.com/product/${productId}`;

    try {
      await Share.share({
        message: `Check out this product!`,
        url: webUrl, // iOS
        // Android uses message for URL
      });
    } catch (error) {
      console.error('Share failed:', error);
    }
  };

  return (
    <Pressable onPress={shareProduct}>
      <Ionicons name="share-outline" size={24} />
    </Pressable>
  );
}

Push Notification Deep Links

Push notifications often need to navigate users to specific screens. Combining deep links with push notifications creates powerful engagement opportunities.

Setting Up Push Notifications

# Install Expo notifications
npx expo install expo-notifications expo-device
// services/notifications.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import Constants from 'expo-constants';

// Configure how notifications are handled when app is in foreground
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export async function registerForPushNotifications(): Promise<string | null> {
  if (!Device.isDevice) {
    console.log('Push notifications require a physical device');
    return null;
  }

  // Check existing permissions
  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  // Request permissions if not granted
  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    console.log('Push notification permission denied');
    return null;
  }

  // Get the Expo push token
  const token = await Notifications.getExpoPushTokenAsync({
    projectId: Constants.expoConfig?.extra?.eas?.projectId,
  });

  // Android requires a notification channel
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'Default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#FF231F7C',
    });
  }

  return token.data;
}

Handling Notification Navigation

// hooks/useNotificationNavigation.ts
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';

interface NotificationData {
  screen?: string;
  params?: Record<string, string>;
}

export function useNotificationNavigation() {
  const router = useRouter();
  const notificationListener = useRef<Notifications.Subscription>();
  const responseListener = useRef<Notifications.Subscription>();

  useEffect(() => {
    // Handle notification received while app is in foreground
    notificationListener.current = Notifications.addNotificationReceivedListener(
      (notification) => {
        console.log('Notification received:', notification);
        // You can show an in-app alert or update UI
      }
    );

    // Handle notification tap (app in background or closed)
    responseListener.current = Notifications.addNotificationResponseReceivedListener(
      (response) => {
        const data = response.notification.request.content.data as NotificationData;
        handleNotificationNavigation(data);
      }
    );

    // Check if app was opened from a notification
    Notifications.getLastNotificationResponseAsync().then((response) => {
      if (response) {
        const data = response.notification.request.content.data as NotificationData;
        handleNotificationNavigation(data);
      }
    });

    return () => {
      if (notificationListener.current) {
        Notifications.removeNotificationSubscription(notificationListener.current);
      }
      if (responseListener.current) {
        Notifications.removeNotificationSubscription(responseListener.current);
      }
    };
  }, []);

  const handleNotificationNavigation = (data: NotificationData) => {
    if (!data.screen) return;

    // Build the route path
    let path = data.screen;
    
    // Add query params if present
    if (data.params) {
      const queryString = new URLSearchParams(data.params).toString();
      path = `${path}?${queryString}`;
    }

    // Navigate to the screen
    router.push(path as any);
  };
}

Sending Deep Link Notifications

// Backend example: Sending a notification with deep link data
// This would typically be done from your server

// Using Expo's push notification service
const message = {
  to: 'ExponentPushToken[xxxxxx]',
  sound: 'default',
  title: 'New Order Update',
  body: 'Your order #12345 has been shipped!',
  data: {
    screen: '/orders/12345',
    params: {
      tab: 'tracking',
    },
  },
};

await fetch('https://exp.host/--/api/v2/push/send', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(message),
});

Complete Integration Example

// app/_layout.tsx
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import { useNotificationNavigation } from '@/hooks/useNotificationNavigation';
import { registerForPushNotifications } from '@/services/notifications';

export default function RootLayout() {
  // Set up notification navigation handling
  useNotificationNavigation();

  useEffect(() => {
    // Register for push notifications on app start
    registerForPushNotifications().then((token) => {
      if (token) {
        // Send token to your backend
        sendTokenToServer(token);
      }
    });
  }, []);

  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="orders/[id]" options={{ title: 'Order Details' }} />
    </Stack>
  );
}
sequenceDiagram
    participant Server
    participant APNS/FCM
    participant Device
    participant App
    participant Router
    
    Server->>APNS/FCM: Send push with deep link data
    APNS/FCM->>Device: Deliver notification
    
    alt App in foreground
        Device->>App: NotificationReceived event
        App->>App: Show in-app UI
    else App in background/closed
        Device->>Device: Show system notification
        Note over Device: User taps notification
        Device->>App: Launch with notification data
        App->>Router: Navigate to deep link screen
    end
                

Testing Push Notifications

// Development: Schedule a local notification to test
import * as Notifications from 'expo-notifications';

async function testNotification() {
  await Notifications.scheduleNotificationAsync({
    content: {
      title: 'Test Deep Link',
      body: 'Tap to navigate to product',
      data: {
        screen: '/product/123',
      },
    },
    trigger: {
      seconds: 5,
    },
  });
}

// Add a dev button somewhere in your app
<Button title="Test Notification" onPress={testNotification} />

Hands-On Exercises

Exercise 1: Basic Deep Linking

Set up deep linking for an e-commerce app:

  • Configure URL scheme shopapp://
  • Create routes for products, categories, and cart
  • Test deep links: shopapp://product/123
  • Handle query params: shopapp://search?q=shoes
βœ… Solution
// app.json
{
  "expo": {
    "scheme": "shopapp"
  }
}
// File structure
app/
β”œβ”€β”€ _layout.tsx
β”œβ”€β”€ index.tsx              β†’ shopapp://
β”œβ”€β”€ product/[id].tsx       β†’ shopapp://product/123
β”œβ”€β”€ category/[slug].tsx    β†’ shopapp://category/shoes
β”œβ”€β”€ search.tsx             β†’ shopapp://search?q=...
└── cart.tsx               β†’ shopapp://cart
// app/search.tsx
import { useLocalSearchParams } from 'expo-router';
import { View, Text, FlatList } from 'react-native';

export default function SearchScreen() {
  const { q, sort, category } = useLocalSearchParams<{
    q?: string;
    sort?: string;
    category?: string;
  }>();

  return (
    <View>
      <Text>Searching for: {q || 'all products'}</Text>
      {category && <Text>Category: {category}</Text>}
      {/* Search results */}
    </View>
  );
}

// Test with: shopapp://search?q=shoes&category=running

Exercise 2: Universal Links Setup

Configure universal links for https://myshop.com:

  • Add associated domains to app.json
  • Create the apple-app-site-association file
  • Create the assetlinks.json file for Android
  • Map paths: /product/*, /category/*, /user/*
βœ… Solution
// app.json
{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.myshop.app",
      "associatedDomains": [
        "applinks:myshop.com",
        "applinks:www.myshop.com"
      ]
    },
    "android": {
      "package": "com.myshop.app",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            { "scheme": "https", "host": "myshop.com", "pathPrefix": "/product" },
            { "scheme": "https", "host": "myshop.com", "pathPrefix": "/category" },
            { "scheme": "https", "host": "myshop.com", "pathPrefix": "/user" }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}
// .well-known/apple-app-site-association
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.myshop.app",
        "paths": [
          "/product/*",
          "/category/*",
          "/user/*",
          "NOT /admin/*"
        ]
      }
    ]
  }
}
// .well-known/assetlinks.json
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.myshop.app",
    "sha256_cert_fingerprints": ["YOUR_SHA256"]
  }
}]

Exercise 3: Protected Deep Links

Implement authentication-aware deep linking:

  • Store pending deep link when user isn't authenticated
  • Redirect to login screen
  • After successful login, navigate to original destination
πŸ’‘ Hint

Use AsyncStorage to persist the pending URL. In your auth context, after successful login, check for a pending URL and navigate to it. Remember to clear the stored URL after using it.

Exercise 4: Push Notification Navigation

Set up push notifications with deep linking:

  • Register for push notifications
  • Handle notification tap to navigate
  • Support different notification types (order, message, promo)
  • Test with local notifications
βœ… Solution
// hooks/useNotificationNavigation.ts
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';

type NotificationType = 'order' | 'message' | 'promo';

interface NotificationData {
  type: NotificationType;
  id: string;
}

export function useNotificationNavigation() {
  const router = useRouter();

  useEffect(() => {
    const subscription = Notifications.addNotificationResponseReceivedListener(
      (response) => {
        const data = response.notification.request.content.data as NotificationData;
        navigateByType(data);
      }
    );

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

  const navigateByType = (data: NotificationData) => {
    switch (data.type) {
      case 'order':
        router.push(`/orders/${data.id}`);
        break;
      case 'message':
        router.push(`/messages/${data.id}`);
        break;
      case 'promo':
        router.push(`/promo/${data.id}`);
        break;
      default:
        router.push('/');
    }
  };
}

// Test function
async function sendTestNotification(type: NotificationType) {
  await Notifications.scheduleNotificationAsync({
    content: {
      title: `Test ${type} notification`,
      body: 'Tap to navigate',
      data: { type, id: '123' },
    },
    trigger: { seconds: 3 },
  });
}

Summary

You've mastered deep linkingβ€”connecting your app to the wider world through URLs, universal links, and push notifications. Users can now navigate directly to any content in your app from anywhere.

🎯 Key Takeaways

  • URL Schemes: Simple myapp:// links, but no fallback if app isn't installed
  • Universal Links: HTTPS URLs that work on web and app, require domain verification
  • Expo Router: Automatic deep link supportβ€”file paths become URL paths
  • Query Parameters: Access with useLocalSearchParams()
  • Protected Routes: Store pending URL, redirect to login, resume after auth
  • Push Notifications: Include data payload with screen path
  • Testing: Use npx uri-scheme open or local notifications

πŸ”— Deep Linking Checklist

Development:
βœ“ Configure scheme in app.json
βœ“ Test with npx uri-scheme open
βœ“ Verify routes match file structure

Production:
βœ“ Set up universal links / app links
βœ“ Host .well-known files on your domain
βœ“ Verify domain ownership
βœ“ Test on real devices
βœ“ Handle auth-protected routes
βœ“ Add analytics tracking
βœ“ Test push notification deep links

What's Next?

In the final lesson of this module, we'll explore Advanced Navigation Patternsβ€”custom transitions, gesture handling, modal stacks, and performance optimization for complex navigation.