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
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"
Universal Links & App Links
Universal Links (iOS) and App Links (Android) use standard HTTPS URLs that can open either your app or your website. They require domain verification to prove you own both the app and website.
Benefits Over URL Schemes
β Universal/App Links Advantages
- Fallback to web: If app isn't installed, opens in browser
- Secure: Only verified owners can claim a domain
- Better UX: Works in emails, messages, social media
- Single URL: Same link works for app and web
Configuration for iOS (Universal Links)
// app.json
{
"expo": {
"ios": {
"bundleIdentifier": "com.mycompany.myapp",
"associatedDomains": [
"applinks:myapp.com",
"applinks:www.myapp.com"
]
}
}
}
Host an Apple App Site Association file at your domain:
// https://myapp.com/.well-known/apple-app-site-association
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.com.mycompany.myapp",
"paths": [
"/product/*",
"/user/*",
"/settings",
"/NOT /admin/*"
]
}
]
}
}
Configuration for Android (App Links)
// app.json
{
"expo": {
"android": {
"package": "com.mycompany.myapp",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "myapp.com",
"pathPrefix": "/product"
},
{
"scheme": "https",
"host": "myapp.com",
"pathPrefix": "/user"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
Host a Digital Asset Links file at your domain:
// https://myapp.com/.well-known/assetlinks.json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.mycompany.myapp",
"sha256_cert_fingerprints": [
"YOUR_SHA256_FINGERPRINT"
]
}
}
]
Getting 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
# Or get it from EAS Build
eas credentials
sequenceDiagram
participant User
participant Browser
participant OS
participant Server
participant App
User->>Browser: Clicks https://myapp.com/product/123
Browser->>OS: Handle URL
OS->>Server: Fetch .well-known/apple-app-site-association
Server-->>OS: Return association file
alt App is installed & verified
OS->>App: Open with URL
App->>App: Navigate to /product/123
else App not installed
OS->>Browser: Open URL normally
Browser->>Server: Load webpage
end
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>
);
}
Handling Incoming Links
When your app receives a deep link, you may need to perform additional logicβauthentication checks, data fetching, or analytics trackingβbefore navigating.
Listening for Links
// hooks/useDeepLinkHandler.ts
import { useEffect } from 'react';
import * as Linking from 'expo-linking';
import { useRouter } from 'expo-router';
export function useDeepLinkHandler() {
const router = useRouter();
useEffect(() => {
// Handle link that opened the app
const handleInitialURL = async () => {
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
handleDeepLink(initialUrl);
}
};
// Handle links while app is open
const subscription = Linking.addEventListener('url', ({ url }) => {
handleDeepLink(url);
});
handleInitialURL();
return () => {
subscription.remove();
};
}, []);
const handleDeepLink = (url: string) => {
console.log('Deep link received:', url);
// Parse the URL
const { path, queryParams } = Linking.parse(url);
// Track analytics
analytics.trackDeepLink(url);
// Expo Router handles navigation automatically,
// but you can add custom logic here if needed
};
}
Authentication-Aware Deep Links
// Handle deep links that require authentication
import { useEffect, useState } from 'react';
import * as Linking from 'expo-linking';
import { useRouter } from 'expo-router';
import { useAuth } from '@/context/AuthContext';
import AsyncStorage from '@react-native-async-storage/async-storage';
const PENDING_DEEP_LINK_KEY = 'pending_deep_link';
export function useAuthenticatedDeepLinks() {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
const [pendingUrl, setPendingUrl] = useState<string | null>(null);
useEffect(() => {
const handleUrl = async (url: string) => {
const { path } = Linking.parse(url);
// Check if route requires authentication
const requiresAuth = isProtectedRoute(path);
if (requiresAuth && !isAuthenticated) {
// Store the URL and redirect to login
await AsyncStorage.setItem(PENDING_DEEP_LINK_KEY, url);
router.replace('/(auth)/login');
} else {
// Navigate directly
router.push(path || '/');
}
};
const subscription = Linking.addEventListener('url', ({ url }) => {
handleUrl(url);
});
// Check for initial URL
Linking.getInitialURL().then((url) => {
if (url) handleUrl(url);
});
return () => subscription.remove();
}, [isAuthenticated]);
// After login, check for pending deep link
useEffect(() => {
const checkPendingLink = async () => {
if (isAuthenticated && !isLoading) {
const pendingUrl = await AsyncStorage.getItem(PENDING_DEEP_LINK_KEY);
if (pendingUrl) {
await AsyncStorage.removeItem(PENDING_DEEP_LINK_KEY);
const { path } = Linking.parse(pendingUrl);
router.replace(path || '/');
}
}
};
checkPendingLink();
}, [isAuthenticated, isLoading]);
}
function isProtectedRoute(path: string | null): boolean {
if (!path) return false;
const protectedPaths = [
'/profile',
'/settings',
'/orders',
'/checkout',
];
return protectedPaths.some(p => path.startsWith(p));
}
Validating Deep Link Parameters
// app/product/[id].tsx
import { useLocalSearchParams, Redirect } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
export default function ProductScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
// Validate the ID format
if (!id || !/^\d+$/.test(id)) {
return <Redirect href="/products" />;
}
// Fetch product data
const { data: product, isLoading, error } = useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
});
// Handle product not found
if (error) {
return <Redirect href="/products?error=not-found" />;
}
if (isLoading) {
return <LoadingScreen />;
}
return (
<ProductDetails product={product} />
);
}
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
datapayload with screen path - Testing: Use
npx uri-scheme openor 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.