All articles
Mobile DevelopmentSeptember 10, 2026 6 min read

Deep Links That Actually Work: Universal Links, App Links, and Expo Router in 2026

Deep linking looks trivial until a marketing email opens Safari instead of your app. Here's how we wire Universal Links, Android App Links, and Expo Router so links land where they should — every time.

Deep Links That Actually Work: Universal Links, App Links, and Expo Router in 2026

Deep linking is one of those features that looks like a two-line config until you ship it. Then a marketing email opens Safari, a QR code lands on your web fallback, and Android decides your https:// link should open in Chrome forever. This is the setup we use on production React Native + Expo apps in 2026, and the specific things that trip teams up.

Why deep links are still hard in 2026

The fundamentals haven't changed: iOS uses Universal Links (backed by an apple-app-site-association file) and Android uses App Links (backed by assetlinks.json). Both are https:// URLs that the OS intercepts and routes into your app when the association is verified.

What has changed:

  • Expo Router is now the default routing story for most new Expo apps, and it handles URL-to-screen mapping declaratively based on your file system.
  • iOS 17+ tightened AASA caching behaviour and made the applinks: service more strict about redirects.
  • Android 12+ requires domain verification for the OS to auto-open your app; without it, users see the disambiguation dialog or the link opens in the browser.
  • Custom schemes (myapp://) still work for internal navigation but are effectively dead for anything a user might click from an email, SMS, or another app. Both platforms suppress them.

If you're still relying on Linking.openURL('myapp://...') for onboarding, you're leaking users.

The setup we actually use

Assume a bare-bones Expo app with Expo Router and a domain example.com. The goal: https://example.com/orders/123 opens the app on /orders/[id] when installed, or falls back to the mobile web page otherwise.

1. Configure app.json for both platforms

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

Two things to watch:

  • The scheme field is your custom scheme for internal use (auth callbacks, dev tooling). Keep it, but don't rely on it for user-facing links.
  • autoVerify: true on Android is what triggers the OS to fetch your assetlinks.json at install time. Miss this and you'll spend hours wondering why the link keeps opening Chrome.

2. Host the association files

On your web domain, serve two files over HTTPS with valid certificates and no redirects:

https://example.com/.well-known/apple-app-site-association (no .json extension, Content-Type: application/json):

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.example.app"],
        "components": [
          { "/": "/orders/*", "comment": "Order detail" },
          { "/": "/products/*" }
        ]
      }
    ]
  }
}

https://example.com/.well-known/assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:...:99"
      ]
    }
  }
]

Get the Android fingerprint from EAS using eas credentials. If you use Play App Signing (you should), use the fingerprint from the Play Console, not your upload key. This is the single most common cause of "App Links don't verify."

3. Let Expo Router handle the routes

With the file structure below, Expo Router maps URLs automatically — no imperative Linking handlers needed for happy-path navigation:

app/
  _layout.tsx
  index.tsx
  orders/
    [id].tsx
  products/
    [slug].tsx

When the OS opens your app with https://example.com/orders/123, Expo Router pushes /orders/[id] with id: '123'. That's it. You can still use useLocalSearchParams() inside the screen to read the ID.

For cold-start deep links (app not running), Expo Router reads the initial URL for you. For warm-start (app in background), the same route resolver fires. You only need imperative Linking.addEventListener code if you're doing something exotic like queuing links behind an auth wall.

The pitfalls we keep hitting

iOS: AASA caching is brutal

Apple's CDN caches the AASA file aggressively — sometimes for days. During development, put your build on a device, delete the app, reboot the device, then reinstall. Even then, iOS may use a cached association from a previous fetch. The swcutil command on macOS can dump the current state, but honestly the reliable fix is time and patience.

Also: your AASA must be served with no redirects. If your CDN redirects /.well-known/apple-app-site-association from www. to apex (or vice versa), iOS will silently give up. Test with:

curl -I https://example.com/.well-known/apple-app-site-association

You want a 200, Content-Type: application/json, and no Location header.

Android: the fingerprint gotcha

On Android, if autoVerify fails (wrong fingerprint, unreachable file, malformed JSON), the OS marks your app as "not verified" and won't retry until reinstall or a settings toggle. Users then get the disambiguation dialog forever.

Google's Digital Asset Links tester is your friend here. Run it before every release that changes signing keys.

Marketing links from email clients

Gmail, Outlook, and most email apps run link previews and safe-browsing checks that can "pre-open" your links. On iOS this usually still hands off to your app, but on Android some clients open an embedded webview instead of the system browser, and App Links don't fire from embedded webviews.

The workaround: send users to a short redirect on your own domain (https://example.com/l/xyz) that renders a lightweight HTML page with a call-to-action button. The button click is a real user gesture that triggers proper App Link resolution.

The web fallback matters

If a user doesn't have the app installed and clicks a Universal Link, iOS just opens the URL in Safari. Your web team needs to actually render something meaningful at /orders/123 — ideally with a smart banner suggesting the app install. Don't ship the mobile app without coordinating this.

When to reach for native modules

Expo's expo-linking and Expo Router cover 95% of what apps need. You'd drop to native (Swift NSUserActivity handling, Kotlin intent parsing) in a few cases:

  • Handoff between web Safari and your app mid-session (rare, but useful for productivity apps).
  • Custom App Clips or Instant Apps behaviour.
  • Deferred deep linking with install attribution — this is where third parties like Branch, Adjust, or AppsFlyer earn their keep, because iOS doesn't natively pass a URL through the App Store install flow.

We've built plenty of apps where a Branch SDK bridged the pre-install gap, and plenty more where the product simply didn't need it. Ask what you actually need before adding a paid dependency.

Testing without losing your mind

A quick recipe for local testing:

# iOS simulator
xcrun simctl openurl booted "https://example.com/orders/123"

# Android emulator or device
adb shell am start -a android.intent.action.VIEW \
  -d "https://example.com/orders/123" com.example.app

These bypass the OS association check, so they'll open the app even if AASA/assetlinks aren't set up yet. That's useful for verifying your route handling in isolation, but you still need to test real link clicks (from Notes, Messages, an actual email) before shipping.

A minimal CI check

We add a smoke test to CI that curls both association files and asserts the response shape. Cheap, catches regressions when someone "cleans up" the web repo:

curl -fsS https://example.com/.well-known/apple-app-site-association \
  | jq -e '.applinks.details[0].appIDs | length > 0'

curl -fsS https://example.com/.well-known/assetlinks.json \
  | jq -e '.[0].target.sha256_cert_fingerprints | length > 0'

Where we'd start

If you're greenfield: set up Expo Router first, decide on your URL structure with the web team, then wire the association files before you write a single deep-link handler. If you're retrofitting a legacy app: audit every Linking.openURL call, replace user-facing myapp:// schemes with https:// equivalents, and ship the AASA/assetlinks changes a week before the app update so iOS's cache has time to warm.

And whatever you do, test on a real device with a real link from a real email. Simulators lie. If you want a second pair of eyes on your setup, our mobile team has walked through this checklist more times than we'd like to admit.

#React Native#Expo#Deep Linking#iOS#Android#Mobile

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project