React Native deep linking — the complete setup guide
← Back
April 4, 2026React Native9 min read

React Native deep linking — the complete setup guide

Published April 4, 20269 min read

Deep linking is one of those features that looks simple in the docs and takes a full day the first time you set it up. Between iOS universal links, Android App Links, URI schemes, Expo Router configuration, and handling the case where the app is not open yet, there are a dozen places to go wrong. Here is the complete setup with everything that actually matters.

Two types of links

You need to support both:

  • Universal Links (iOS) / App Links (Android): https://myapp.com/item/123 — opens the app if installed, falls back to the website. Works from email, SMS, Notes, anywhere HTTPS links appear.
  • URI scheme: myapp://item/123 — always opens the app, no web fallback. Required for OAuth redirects and some third-party integrations.

Step 1: Configure the URL scheme (Expo)

json
{
  "expo": {
    "scheme": "myapp",
    "ios": {
      "bundleIdentifier": "com.example.myapp",
      "associatedDomains": ["applinks:myapp.com"]
    },
    "android": {
      "package": "com.example.myapp",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            {
              "scheme": "https",
              "host": "myapp.com",
              "pathPrefix": "/"
            }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

Step 2: HTTPS domain verification files

Both iOS and Android require a verification file on your domain to prove you own it.

iOS — serve at https://myapp.com/.well-known/apple-app-site-association (no .json extension):

json
{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.example.myapp"],
        "components": [
          { "/": "/item/*" },
          { "/": "/profile/*" },
          { "/": "/invite/*" }
        ]
      }
    ]
  }
}

Android — serve at https://myapp.com/.well-known/assetlinks.json:

json
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.myapp",
    "sha256_cert_fingerprints": ["YOUR_SHA256_FINGERPRINT"]
  }
}]

Both files must be served with Content-Type: application/json and accessible without redirects. Test with Apple's validation tool and Google's Statement List Generator.

Step 3: Expo Router link configuration

typescript
// app.config.ts
export default {
  expo: {
    scheme: 'myapp',
    experiments: {
      typedRoutes: true,
    },
  },
};

With Expo Router, file paths map directly to URLs. app/item/[id].tsx handles both myapp://item/123 and https://myapp.com/item/123 automatically. No additional routing configuration needed.

Step 4: Handling incoming links

typescript
// app/item/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { useEffect } from 'react';

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

  useEffect(() => {
    // Track deep link open in analytics
    analytics.track('deep_link_opened', { screen: 'item', id });
  }, [id]);

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

Step 5: The tricky case — auth required to view the link destination

A user taps myapp://invite/abc123 before logging in. Your auth check redirects to login. After login, you need to redirect them to the original destination. Here is how:

typescript
// contexts/AuthContext.tsx
import { useStorageState } from '../hooks/useStorageState';
import { router, useSegments, usePathname } from 'expo-router';
import { useEffect, useRef } from 'react';

export function AuthProvider({ children }) {
  const [user, setUser] = useAuthState();
  const segments = useSegments();
  const pathname = usePathname();
  const pendingLinkRef = useRef<string | null>(null);

  useEffect(() => {
    const inAuthGroup = segments[0] === '(auth)';

    if (!user && !inAuthGroup) {
      // Save the current path so we can redirect after login
      pendingLinkRef.current = pathname;
      router.replace('/login');
    }

    if (user && inAuthGroup) {
      // After login, check for pending link
      const destination = pendingLinkRef.current ?? '/';
      pendingLinkRef.current = null;
      router.replace(destination);
    }
  }, [user, segments]);

  return <AuthContext.Provider value={{ user, setUser }}>{children}</AuthContext.Provider>;
}

Step 6: OAuth redirect URI

When using OAuth (Google, GitHub, etc.), you need to register myapp://oauth/callback as a redirect URI. Handle it:

typescript
// app/oauth/callback.tsx
import { useLocalSearchParams, router } from 'expo-router';
import { useEffect } from 'react';

export default function OAuthCallback() {
  const params = useLocalSearchParams();

  useEffect(() => {
    const { code, state, error } = params;
    if (error) {
      router.replace('/login?error=oauth_failed');
      return;
    }
    // Exchange code for token
    exchangeCodeForToken(code as string, state as string)
      .then(() => router.replace('/'))
      .catch(() => router.replace('/login?error=token_exchange_failed'));
  }, []);

  return <LoadingScreen />;
}

Testing deep links

bash
# iOS Simulator
xcrun simctl openurl booted "myapp://item/123"
xcrun simctl openurl booted "https://myapp.com/item/123"

# Android Emulator
adb shell am start -W -a android.intent.action.VIEW   -d "myapp://item/123" com.example.myapp

# Expo Go (development)
npx uri-scheme open myapp://item/123 --ios

The most common mistake: forgetting to rebuild the native app after changing app.json. Configuration changes require a new build — expo prebuild or EAS Build. Shaking the device and hot-reloading does not pick up app.json changes.

Share this
← All Posts9 min read