Skip to content
CodeOnAir
← All posts

Expo deep linking: Universal Links and App Links with Expo Router

Set up iOS Universal Links and Android App Links in an Expo app: app.json, the two verification files, Expo Router routing, testing on devices and deferred deep links.

CodeOnAir team25 October 20268 min read

Expo makes the in-app half of deep linking easy: with Expo Router, every screen already has a URL. The other half lives outside your JavaScript. The domain has to prove it belongs to your app, the native build has to list that domain, and someone has to remember the link while a new user installs from the store.

This guide sets up iOS Universal Links and Android App Links in an Expo app, routes them with Expo Router, shows how to test them, and covers deferred deep links for users who don't have the app yet.

Three kinds of link in an Expo app

Kind Example What it needs
Custom scheme myapp://product/42 scheme in app.json. Does nothing if the app isn't installed.
Universal Link / App Link https://links.example.com/product/42 associatedDomains and intentFilters in app.json, plus two files hosted on the domain.
Deferred deep link The same HTTPS link, tapped before install A service that records the tap and hands the link to the app on its first open.

Expo's linking overview puts it plainly: "Support for incoming links in Expo Go is limited." Everything below needs a development build (or npx expo prebuild), because Universal Links and App Links are part of the native app that the stores install.

Step 1: Choose a link domain and a scheme

Use a subdomain just for links, such as links.example.com. Apple notes that if a user is already on your site in Safari and taps a link to the same domain, Safari keeps them in the browser, so a separate subdomain behaves better than your main website.

Set a scheme as a fallback for in-app browsers that won't open Universal Links:

{
  "expo": {
    "scheme": "myapp",
    "ios": { "bundleIdentifier": "com.example.shop" },
    "android": { "package": "com.example.shop" }
  }
}

Step 2: Add Associated Domains for iOS

{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.example.shop",
      "associatedDomains": ["applinks:links.example.com"]
    }
  }
}

Leave out the protocol. Expo's iOS Universal Links guide calls out applinks:https://… as a common mistake that stops links from working. Each subdomain needs its own entry: applinks:example.com doesn't cover www.example.com.

When you run eas build, EAS syncs supported capabilities from your entitlements to the Apple Developer Console (iOS capabilities docs). If you manage signing yourself, turn on Associated Domains for the App ID on developer.apple.com and regenerate the provisioning profile.

Step 3: Add an autoVerify intent filter for Android

{
  "expo": {
    "android": {
      "package": "com.example.shop",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [{ "scheme": "https", "host": "links.example.com" }],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

autoVerify: true is what makes Android check your domain and open the app without a chooser. Add a pathPrefix to the data entry if only some paths should open the app. Only list hosts that actually serve the verification file. On Android 11 and earlier, one failing host can stop all of the app's links verifying.

Step 4: Host the two verification files

Both platforms fetch a JSON file from your domain to confirm the app is allowed to open its links:

  • iOS: https://links.example.com/.well-known/apple-app-site-association
  • Android: https://links.example.com/.well-known/assetlinks.json

They must be served over HTTPS with HTTP 200, no redirects and a JSON content type. A minimal pair:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCDE12345.com.example.shop"],
        "components": [{ "/": "/*" }]
      }
    ]
  }
}
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.shop",
      "sha256_cert_fingerprints": ["14:6D:E9:83:…:44:E5"]
    }
  }
]

ABCDE12345 is your 10-character Apple Team ID. For the Android fingerprint, Expo's App Links guide suggests eas credentials -p android and copying the SHA256 Fingerprint for your build profile. If you ship through Google Play with Play App Signing, also add the app signing key's SHA-256 from Play Console, because Google re-signs the app users install.

If your website is an Expo Router web app, Expo's docs place these files in public/.well-known/. Otherwise, serve them from whatever hosts the domain. Our setup guide for apple-app-site-association and assetlinks.json covers both formats, path rules and hosting mistakes in detail.

Step 5: Route incoming links with Expo Router

With Expo Router, a link's path is matched against your file routes. https://links.example.com/product/42 opens app/product/[id].tsx with id set to "42", whether the app was closed or already running:

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

export default function Product() {
  const { id } = useLocalSearchParams<{ id: string }>();
  // load and show product `id`
}

When incoming URLs don't match your routes, for example marketing paths or short links, rewrite them in app/+native-intent.tsx. Expo Router calls redirectSystemPath with the incoming path and an initial flag that is true when the link launched the app (native intent docs):

// app/+native-intent.tsx
export function redirectSystemPath({ path, initial }: { path: string; initial: boolean }) {
  try {
    const url = new URL(path, "myapp://app.home");
    if (url.pathname.startsWith("/p/")) {
      return `/product/${url.pathname.slice(3)}`; // /p/42 → /product/42
    }
    return path;
  } catch {
    return "/";
  }
}

Not using Expo Router? expo-linking gives you the same information: Linking.useLinkingURL() returns the URL that opened the app and updates when a new one arrives, and Linking.parse(url) splits it into path and query parameters.

Step 6: Build and test on a device

Config changes in app.json only take effect in a new native build:

npx expo run:ios        # or: eas build --profile development --platform ios
npx expo run:android    # or: eas build --profile development --platform android

Then test the way a user would:

# iOS Simulator, with the app installed
xcrun simctl openurl booted "https://links.example.com/product/42"

# Android: open the link as a browser would
adb shell am start -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE \
  -d "https://links.example.com/product/42"

# Android: did the domain verify?
adb shell pm get-app-links com.example.shop

On an iPhone, paste the link into Notes or Messages and tap it. Typing it into Safari's address bar never opens an app. On Android, pm get-app-links should show verified for your host; anything else means Android couldn't read or match assetlinks.json.

To test against a local or staging server, Expo's guides describe a tunnel: set EXPO_TUNNEL_SUBDOMAIN, start with npx expo start --tunnel, and use the tunnel domain in associatedDomains and intentFilters for a development build.

Our free Universal Link and App Link Validator checks both files on your domain: HTTPS, redirects, content type, JSON structure, whether a given path matches, and whether Apple's CDN has the current copy. If links still open the browser, work through Universal Links not working? An iOS checklist and Android App Links not verified?.

Deferred deep links in Expo

Everything so far assumes the app is installed. When it isn't, the tap goes to the App Store or Play Store, the user installs, opens the app, and Expo Router starts at your home screen. The link is gone, because the stores don't pass it through.

Keeping it takes a service that records the tap and matches it to the app's first open. On Android, the Play Install Referrer carries a click ID through the install, so the match is exact. iOS has no referrer, so services match on signals from the tap and the first launch within a short window. Our post on deferred deep linking on iOS without IDFA explains how that works and where it falls short.

With CodeOnAir AppLinks

The CodeOnAir React Native SDK includes an Expo config plugin. It adds Associated Domains, an autoVerify intent filter for each domain, your scheme, and your App ID to the native config, so Steps 2 and 3 are done for you:

npx expo install @codeonair/deeplink-react-native @react-native-async-storage/async-storage
npx expo install react-native-play-install-referrer   # exact Android install matching
{
  "expo": {
    "plugins": [
      ["@codeonair/deeplink-react-native", {
        "appId": "proj_…",
        "domains": ["shop.codeonair.com"],
        "scheme": "myapp"
      }]
    ]
  }
}

CodeOnAir also builds and hosts apple-app-site-association and assetlinks.json on your link domain from the Team ID, bundle ID, package name and fingerprints you enter, so Step 4 is done too.

In the app, initialise once and handle links from one callback:

// app/_layout.tsx
import { useEffect } from "react";
import { Stack, router } from "expo-router";
import DeepLink from "@codeonair/deeplink-react-native";

DeepLink.init(); // reads the App ID the plugin wrote

export default function RootLayout() {
  useEffect(
    () =>
      DeepLink.onLink((link) => {
        // link.path, link.params, link.isDeferred
        router.push({ pathname: link.path, params: link.params });
      }),
    [],
  );
  return <Stack />;
}

onLink fires for links that open a running app, links that launch it, and the deferred link on the first launch after install, with isDeferred: true. CodeOnAir short links such as shop.codeonair.com/summer-sale use a slug, not your app's route, so add a rule to +native-intent.tsx that sends paths on your link domain to /, and let onLink navigate to link.path. That way Expo Router doesn't try to open a route called summer-sale.

On iOS, exact clipboard matching is opt-in. Turn on clipboard matching in the app's settings in the dashboard, pass getClipboardString: () => Clipboard.getStringAsync() from expo-clipboard to DeepLink.init, and iOS will ask the user to allow the paste on first launch. The React Native and Expo SDK docs cover the rest.

FAQ

Do Universal Links work in Expo Go?

Not for your own domain. Associated Domains and intent filters are compiled into the native app, and Expo Go is Expo's app, not yours. Use a development build.

Does Expo Router handle deep links automatically?

Yes. Expo's docs say deep linking is automatically enabled for all of your app's screens. A link's path opens the matching file route. Use +native-intent.tsx to rewrite paths that don't match.

Where do I put apple-app-site-association in an Expo project?

On the website that serves your link domain, at /.well-known/apple-app-site-association. If that site is built with Expo Router for web, Expo's docs put it in public/.well-known/. It doesn't go in the mobile app.

Why does my link open the browser after I changed app.json?

app.json changes need a new native build. Rebuild with npx expo run:ios / run:android or EAS Build, reinstall, and test again by tapping the link, not typing it.

How do I get the SHA-256 fingerprint for assetlinks.json in Expo?

Run eas credentials -p android and copy the SHA256 Fingerprint for your build profile. For Play Store installs, also add the app signing key's SHA-256 from Play Console.

Set up Expo deep links with CodeOnAir

CodeOnAir AppLinks hosts the verification files, gives every app a link domain, and adds deferred deep linking and link analytics. The Free plan includes AppLinks on one URL prefix, paid plans add more prefixes, longer attribution windows and analytics (pricing), and every new workspace gets 30 days of Growth. Sign-ups are currently by invitation: request access.


Expo details checked on 25 October 2026 against Expo's linking, Universal Links, App Links and native intent documentation.

Your first AppLink in five minutes.

Create an app, pick a subdomain, and share a link that opens your app. Try Growth free for 30 days — no card needed.