# Deep links that open the right screen: production checklist for Expo apps

> Source: <https://dev.to/davekurian/deep-links-that-open-the-right-screen-production-checklist-for-expo-apps-2m05>
> Published: 2026-09-07 08:31:27+00:00

Password-reset emails, referral invites, and order confirmations all end the same way: a link that should land on the right screen. In development the link works because the simulator is warm, the session is fresh, and you tap it from the same device. In production it breaks because the app was killed, the session expired, or the user pasted the link into a notes app first.

Deep linking is not one feature. It is three layers that have to agree: the operating system deciding your app owns the URL, the router mapping that URL to a screen, and your app handling whatever state it wakes up in. When AI coding tools scaffold the router for you, the middle layer looks done while the other two are still missing. This post closes that gap for Expo apps built with Expo Router.

Most production deep-link bugs fall into four buckets. First, the OS never hands the URL to your app because the domain association file is missing or the native config is wrong, so the link opens in the browser instead. Second, the app opens but lands on the home screen because the route path does not match the URL structure. Third, the app lands correctly on a warm start but drops the destination on a cold start, when the JavaScript bundle loads after the OS delivers the intent. Fourth, the destination screen assumes an authenticated user, redirects to login, then forgets where it was going.

Each bucket needs a different fix, which is why retesting the happy path never resolves production reports. You need the OS association verified, the route mapping explicit, and the cold-start plus unauthenticated paths handled as first-class cases. The checklist below walks through all three in the order that fails fastest.

If your auth layer is still shaky, fix that alongside linking. The guard pattern in our [Expo Router auth guards guide](https://dev.to/blog/expo-router-auth-guards-production) pairs well with this post because every protected deep link eventually meets an expired session.

A custom scheme such as `otfkit://orders/123` is easy to configure and fine for local testing. It is a poor default for production because any app can claim the same scheme, there is no ownership proof, and messaging apps and email clients handle custom schemes inconsistently. Users who long-press, preview, or paste the link often end up somewhere unexpected.

Universal linking solves ownership. On Android, App Links bind an `https` URL to your app through an `intent-filter` plus a hosted `assetlinks.json` file. On iOS, Universal Links bind an `https` URL through an `associatedDomains` entitlement plus a hosted `apple-app-site-association` file. When both sides match, the OS opens your app directly with no disambiguation dialog. When the app is not installed, the same URL falls back to your website, which is exactly the behavior a referral or receipt link should have.

The practical rule is simple: keep the custom scheme as a development convenience, ship universal links as the real path, and make both resolve to the same routes. That way QA can test with the scheme while users only ever see `https` links.

Expo Router enables deep linking for every file route automatically, which removes a whole class of manual mapping bugs. Your job is to keep the URL structure stable and to avoid overriding the default behavior unless you have a concrete reason.

Start by defining the canonical URL shape in `app.json` before adding screens. One scheme for development, one web domain for production, with the native association declared in the platform sections covered below.

```
{
  "expo": {
    "scheme": "otfkit",
    "extra": {
      "webDomain": "https://otf-kit.dev"
    }
  }
}
```

Map each linkable destination to a file route and keep the segments identical to the web path. A receipt at `https://otf-kit.dev/blog/expo-sqlite-offline-cache-apps` resolves through the same segment structure as the screen file, not through a differently named screen plus a manual redirect. The fewer translations between URL and route, the fewer places a cold start can lose the parameter.

``` js
// app/orders/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { Text, View } from 'react-native';

export default function OrderScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  if (!id) return <Text>Missing order id.</Text>;
  return (
    <View>
      <Text>Order {id}</Text>
    </View>
  );
}
```

Resist writing a URL parser in front of the router. If you find yourself parsing paths before navigation, the route structure has drifted from the link structure. Rename the routes instead. For apps with marketing pages plus authenticated screens, keep one linking config owned by the router and document which paths are public and which require a session.

Android decides ownership through two artifacts that must agree: the `intent-filter` in the manifest and the `assetlinks.json` file on your domain. Expo config plugins generate the manifest entry from `app.json`, so declare the domain there rather than editing native files by hand.

```
{
  "expo": {
    "android": {
      "package": "dev.otfkit.app",
      "intentFilters": [
        {
          "action": "VIEW",
          "data": [
            {
              "scheme": "https",
              "host": "otf-kit.dev",
              "pathPrefix": "/blog"
            }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}
```

Host the association file at `https://otf-kit.dev/.well-known/assetlinks.json` with the exact package name and the SHA-256 fingerprint of your production keystore. The common failure is testing with the debug fingerprint and shipping the release build, or vice versa. Record both fingerprints during setup and verify the hosted file returns JSON with a `200` status and no redirect.

```
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "dev.otfkit.app",
      "sha256_cert_fingerprints": [
        "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:C3:8B:65:76:36:E8:94:CE:AA:AC"
      ]
    }
  }
]
```

After installing a production-signed build, test from a real surface: an SMS message, an email, or a chat message, not solely through debug bridges. If Android still shows a chooser dialog, verification failed. Re-check the host, the fingerprint, and whether your CDN serves the JSON file with the wrong content type. Verification state survives updates, so a fix here stays fixed.

iOS follows the same two-sided pattern with different file names. Declare the domain in `associatedDomains` and host the `apple-app-site-association` file at the domain root or under `.well-known`, served over `https` with JSON content and no redirects.

```
{
  "expo": {
    "ios": {
      "bundleIdentifier": "dev.otfkit.app",
      "associatedDomains": ["applinks:otf-kit.dev"]
    }
  }
}
```

The association file lists your Team ID plus bundle identifier and the paths your app claims. Start narrow with the paths you actually handle, then widen. Claiming every path before your router handles them turns each marketing click into an app open your users did not ask for.

```
{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCD1234EF.dev.otfkit.app"],
        "paths": ["/blog/*", "/invite/*", "/reset/*"]
      }
    ]
  }
}
```

Test on a physical device with a development build, because simulator behavior misleads here. Long-pressing a link and choosing your app does not prove Universal Links work; tapping the link in Messages or Mail and landing directly in the app does. If iOS opens Safari instead, check the entitlement in the built profile, confirm the file URL serves without redirects, and remember that iOS caches the association file, so reinstall the app after changing it.

This is also where store readiness matters. A link that opens the wrong screen during App Review reads as a broken app. Run the same tap test on a TestFlight build before submission, following the review-minded pass in our [app store submission checklist](https://dev.to/blog/app-store-submission-checklist-ai-built-app).

Routing the URL is half the job. The other half is arriving gracefully when the app wakes from killed, the session is gone, or the parameters are stale.

Treat the incoming URL as untrusted input. Validate the segments, fetch the resource, and render loading, not-found, and login-required states explicitly. A receipt link with a deleted order should show a clear missing screen, not a spinner that never resolves. A referral link opened on a signed-out device should preserve the destination through sign-in and continue afterward instead of dropping the user at the home tab.

``` js
import { useEffect, useState } from 'react';

type LoadState = 'loading' | 'ready' | 'login' | 'missing';

export function useDeepLinkTarget(kind: string, id: string) {
  const [state, setState] = useState<LoadState>('loading');

  useEffect(() => {
    let cancelled = false;
    async function resolve() {
      const session = await getSession();
      if (cancelled) return;
      if (!session) {
        setPendingLink(`/${kind}/${id}`);
        setState('login');
        return;
      }
      const exists = await checkResource(kind, id);
      if (cancelled) return;
      setState(exists ? 'ready' : 'missing');
    }
    resolve();
    return () => {
      cancelled = true;
    };
  }, [kind, id]);

  return state;
}
```

Persist the pending destination before redirecting to login, then consume it once after authentication completes. Keep the pending value in durable storage rather than in-memory state, because the OS may kill the app between the redirect and the login callback. Clear it after a single use so a stale invite does not hijack the next launch.

Cold starts deserve their own test because the timing differs. When the app is killed, the OS launches it and delivers the URL nearly simultaneously, which means session restoration, asset loading, and router mounting race the navigation event. Gate initial navigation on session restoration completing, with a splash screen that waits for a ready flag rather than a fixed timeout. Log the received URL, the resolved route, and the final screen on every cold start during QA so a dropped parameter is visible instead of silent.

Offline behavior matters here too. A link opened with no connectivity should still land on the right screen with cached content or a clear offline state. The cache-first pattern from our [Expo SQLite offline guide](https://dev.to/blog/expo-sqlite-offline-cache-apps) works well for receipt and article screens reached from links.

Deep links need a matrix, not a single tap test. Run each row on a production-signed build on physical devices, with the app in three states: foreground, background, and killed. Record the entry screen for every combination.

Cover at minimum: password reset, invite, receipt, and marketing links; taps from SMS, email, chat apps, and pasted into the browser address bar; signed-in, signed-out, and expired-session states; app installed versus not installed; and Android plus iOS separately, because the association mechanisms fail independently. For each failure, note which layer broke: OS handoff, route mapping, or destination state. That classification tells you whether to fix native config, router structure, or screen logic.

Automate what you can. A small script that opens each canonical URL on simulators and emulators catches route-mapping regressions in continuous integration. Leave the OS association checks for real devices on release candidates, since emulators skip the verification steps that fail most often in production.

Ship the domain files with the same care as code. Pin the association file URLs in your release checklist, monitor them with the same uptime check as your API, and treat any redirect, content-type change, or CDN caching incident as a linking outage. Links are infrastructure once users rely on them.
