Skip to content
Tutorials
Tutorials·6 min read·May 8, 2026

Deep Linking in Expo and React Native: A Working Guide

Universal Links, App Links, custom schemes, and Expo Router — how deep linking actually works in 2026, what each piece does, and the AASA / assetlinks.json mistakes that quietly break it.

Written by
Kaspar Noor
Deep Linking in Expo and React Native: A Working Guide
The mental model in one paragraph

A deep link is a URL that opens a screen in your app instead of a webpage. Custom schemes (myapp://) work everywhere but are unverified — anyone can register them. Universal Links (iOS) and App Links (Android) use HTTPS URLs verified through a file you host on your own domain, which is why they're the default for anything customer-facing. You usually need both.

If you only need a debug-time URL into your app, a custom scheme is enough. If you're sending password reset emails, accepting payment redirects, or sharing content links, you need verified HTTPS deep links. Most production apps end up implementing both because they solve different problems.

What you'll wire up

A custom scheme so debug builds and dev tooling can open the app
iOS Universal Links via a hosted apple-app-site-association file
Android App Links via a hosted assetlinks.json file
Expo Router routes that map URLs to screens with no extra glue code

Custom scheme: the easy half

Add the scheme field to app.json:

{
  "expo": {
    "scheme": "myapp",
    "ios": { "bundleIdentifier": "com.acme.notes" },
    "android": { "package": "com.acme.notes" }
  }
}

Now myapp://settings opens your app. With Expo Router, that URL automatically routes to app/settings.tsx — no manual mapping. You can test from the terminal:

npx uri-scheme open myapp://settings --ios
npx uri-scheme open myapp://settings --android

This is enough for OAuth callbacks, magic links during development, and most internal tooling.

When a user taps https://notes.app/share/abc123, you want the app to open if it's installed and the website to open if it isn't. That's a Universal Link.

It works because the OS, on app install, fetches a file from your domain and verifies that you've authorized the bundle ID to handle that URL.

iOS: apple-app-site-association

Host this file at https://yourdomain.com/.well-known/apple-app-site-association (no .json extension, served as application/json):

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.acme.notes"],
        "components": [
          { "/": "/share/*", "comment": "Shared content" },
          { "/": "/auth/*", "comment": "Auth callbacks" }
        ]
      }
    ]
  }
}

TEAMID is your 10-character Apple Developer Team ID, prefixed to the bundle identifier. You'll find it in Apple Developer → Membership.

Android: assetlinks.json

Host this at https://yourdomain.com/.well-known/assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.acme.notes",
      "sha256_cert_fingerprints": [
        "AB:CD:EF:..."
      ]
    }
  }
]

The fingerprint comes from your signing key. EAS Build prints it during the production build, or you can extract it from the keystore yourself:

keytool -list -v -keystore my-release-key.jks -alias my-app

Use the SHA-256 line, uppercase, with colons.

Tell Expo about both

In app.json:

{
  "expo": {
    "ios": {
      "associatedDomains": ["applinks:notes.app"]
    },
    "android": {
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [{ "scheme": "https", "host": "notes.app", "pathPrefix": "/share" }],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

autoVerify: true is what makes Android actually fetch your assetlinks.json and trust your domain. Without it, Android shows the disambiguation chooser instead of opening the app directly.

Expo Router: the parts that just work

Expo Router maps URLs to file routes. With the config above, https://notes.app/share/abc123 opens app/share/[id].tsx:

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

export default function SharedNote() {
  const { id } = useLocalSearchParams<{ id: string }>();
  // fetch and render
}

If the user has the app installed, the OS opens the app and Expo Router renders that screen. If they don't, the website handles the URL. No extra code on your side.

Cold start vs warm start

When the app is already in memory, the URL just navigates. When the app is launched from a deep link, Expo Router needs to initialize before navigating, and you'll see a brief splash. If you need to gate the deep-link navigation behind auth, do it inside the route component, not by intercepting the URL — by the time Router calls your component, the link state is already set up.

Auth callbacks (Supabase, Convex, OAuth)

For magic links and OAuth callbacks, you usually want a custom scheme so you don't depend on the user's domain being reachable:

import { makeRedirectUri } from "expo-auth-session";

const redirectTo = makeRedirectUri({ scheme: "myapp", path: "auth/callback" });
// myapp://auth/callback

Then in app/auth/callback.tsx you parse the params and finalize the session. Supabase's exchangeCodeForSession and Apple Sign In both follow this pattern.

The painful part is that AASA and assetlinks files only get re-fetched on install or app update — caching them aggressively used to be the most common reason links wouldn't verify.

To force iOS to re-fetch:

  • Delete the app, reboot the device, reinstall.
  • Or, on iOS 14+, open Settings → Developer → Universal Links → enable diagnostics.

To verify Android:

adb shell pm verify-app-links --re-verify com.acme.notes
adb shell pm get-app-links com.acme.notes

You want every entry to say verified. If any say legacy_failure, the assetlinks.json wasn't reachable or didn't match.

The mistakes that quietly cost a day

Serving apple-app-site-association with the wrong content type — must be application/json, no extension on the URL
Hosting the assetlinks.json behind a redirect — Android refuses to follow redirects
Forgetting to add the SHA-256 fingerprint of the upload key when using EAS-managed credentials, not just the release key
Adding new path patterns without bumping the app build — verification only re-runs on install or update
Putting auth tokens in the URL fragment — fine on web, but iOS strips fragments before passing the URL to the app

What you actually need to remember

  • Custom scheme for dev and OAuth callbacks.
  • Universal Links + App Links for any URL a customer would share.
  • Both verification files have to be served from /.well-known/, over HTTPS, with no redirects.
  • Expo Router maps URLs to file routes, so once the verification works, the navigation is free.

If you're starting from scratch, the Shipnative boilerplate has the AASA and assetlinks templates in the landing_page/public/.well-known/ folder, plus the auth callback routes already configured. If you're retrofitting an existing app, the steps above are what you have to do anyway.

For the auth piece specifically, see Apple Sign In with React Native and Expo and Supabase Authentication for React Native.

Ready to ship faster?

Get lifetime access to Shipnative for a one-time payment of $99.