Skip to main content

Overview

Deep linking allows external URLs to open specific screens in your app. Expo Router provides built-in support for deep linking with automatic route matching.

URL Schemes

Configure your scheme

Add a URL scheme to your app.json:
app.json
{
  "expo": {
    "scheme": "myapp",
    "ios": {
      "bundleIdentifier": "com.company.myapp"
    },
    "android": {
      "package": "com.company.myapp"
    }
  }
}
This allows URLs like myapp:// to open your app.

Multiple schemes

app.json
{
  "expo": {
    "scheme": ["myapp", "com.company.myapp"]
  }
}
Universal links allow HTTPS URLs to open your app.

Automatic route matching

Expo Router automatically handles deep links based on your file structure:
app/
├── index.tsx                 # myapp://
├── products/
│   └── [id].tsx             # myapp://products/123
├── user/
│   └── [username].tsx       # myapp://user/john
└── post/
    └── [id].tsx             # myapp://post/456

Access URL parameters

app/products/[id].tsx
import { useLocalSearchParams } from 'expo-router';

export default function ProductScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();

  return (
    <View>
      <Text>Product ID: {id}</Text>
    </View>
  );
}

Handle query parameters

// URL: myapp://products/123?source=email&campaign=summer
import { useLocalSearchParams } from 'expo-router';

export default function ProductScreen() {
  const { id, source, campaign } = useLocalSearchParams<{
    id: string;
    source?: string;
    campaign?: string;
  }>();

  useEffect(() => {
    if (source === 'email') {
      analytics.track('Opened from email', { campaign });
    }
  }, [source, campaign]);

  return <ProductDetails id={id} />;
}

Advanced Deep Linking

Programmatic navigation

import { router } from 'expo-router';
import * as Linking from 'expo-linking';

// Listen for URL events
function useDeepLinking() {
  useEffect(() => {
    const handleUrl = ({ url }: { url: string }) => {
      const { path, queryParams } = Linking.parse(url);
      
      if (path === 'reset-password') {
        router.push({
          pathname: '/reset-password',
          params: { token: queryParams?.token },
        });
      }
    };

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

    // Handle URLs while app is running
    const subscription = Linking.addEventListener('url', handleUrl);

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

Custom URL parsing

import * as Linking from 'expo-linking';

const url = 'myapp://products/123?color=blue&size=large';

const parsed = Linking.parse(url);
console.log(parsed);
// {
//   scheme: 'myapp',
//   path: 'products/123',
//   queryParams: { color: 'blue', size: 'large' }
// }

// Create URLs
const newUrl = Linking.createURL('products/456', {
  queryParams: { featured: 'true' },
});
console.log(newUrl);
// 'myapp://products/456?featured=true'

Deferred deep linking

Handle deep links after authentication:
app/_layout.tsx
import { useEffect, useState } from 'react';
import { router, useSegments } from 'expo-router';
import * as Linking from 'expo-linking';

export default function RootLayout() {
  const [initialUrl, setInitialUrl] = useState<string | null>(null);
  const segments = useSegments();
  const { user } = useAuth();

  // Store initial URL
  useEffect(() => {
    Linking.getInitialURL().then(url => {
      if (url) setInitialUrl(url);
    });
  }, []);

  // Redirect after authentication
  useEffect(() => {
    if (user && initialUrl) {
      const { path, queryParams } = Linking.parse(initialUrl);
      router.push({ pathname: `/${path}`, params: queryParams });
      setInitialUrl(null);
    }
  }, [user, initialUrl]);

  return <Slot />;
}

Using xcrun

# Custom scheme
xcrun simctl openurl booted "myapp://products/123"

# Universal link
xcrun simctl openurl booted "https://myapp.com/products/123"

Using Safari

Open Safari on your simulator/device and enter the URL in the address bar.

Using Notes app

  1. Open Notes app
  2. Create a new note
  3. Type your deep link URL
  4. Tap the link

Debugging

import * as Linking from 'expo-linking';

function useDeepLinkLogger() {
  useEffect(() => {
    const handleUrl = ({ url }: { url: string }) => {
      console.log('Deep link received:', url);
      const parsed = Linking.parse(url);
      console.log('Parsed:', JSON.stringify(parsed, null, 2));
    };

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

    const subscription = Linking.addEventListener('url', handleUrl);
    return () => subscription.remove();
  }, []);
}
Test your apple-app-site-association file:
curl https://myapp.com/.well-known/apple-app-site-association
Or use Apple’s validator.

Troubleshooting

  • Check that you’re using useLocalSearchParams() from expo-router
  • Verify the URL is properly formatted with query parameters
  • Log the received URL to inspect its structure
  • Rebuild your app after changing the scheme in app.json
  • Verify the scheme doesn’t conflict with other apps
  • Check for typos in your URL scheme
Changes to URL schemes and associated domains require a rebuild. Run npx expo prebuild and rebuild your app.

Best Practices

  • Use universal links in production: They provide a better user experience than custom schemes
  • Handle missing parameters gracefully: Not all deep links will include all expected parameters
  • Validate deep link data: Don’t trust user-provided data in URLs
  • Support both authenticated and unauthenticated routes: Store deep links if authentication is required
  • Test on physical devices: Universal links don’t work reliably in simulators
  • Use analytics: Track which deep links drive the most engagement
  • Provide fallbacks: Handle cases where the linked content no longer exists
  • Document your URL structure: Maintain a clear mapping of URLs to app screens