Expo Router Universal Links: Deep Link Setup in 2026 — ContentBuffer guide

Expo Router Universal Links: Deep Link Setup in 2026

K
Kodetra Technologies··8 min read Intermediate

Summary

Open your app from any https URL with Expo Router. iOS + Android setup that actually works.

If your app still opens with myapp:// URLs in 2026, you are leaving the front door locked. Users tap an https link in WhatsApp, Slack, or Gmail and end up on your marketing site instead of inside the app, three taps away from where they wanted to be.

This guide walks through the full Expo Router universal-link setup that ships in Expo SDK 53 and React Native 0.84. By the end you will have an iOS app that opens from https://yourdomain.com/order/123, an Android app that does the same, and a web fallback for users who do not have the app installed yet. No native code, no Xcode juggling, no Android Studio side quests.

Why universal links matter in 2026

Custom schemes (myapp://) still work, but every major messaging app now strips them or warns the user. iOS has refused to render them as tappable links inside Mail since iOS 17. Android 14 silently downgrades them. Universal Links and Android App Links use real https:// URLs that browsers, mail clients, and chat apps treat like any other web link — but the OS intercepts the click and routes it to your app if installed.

The Expo team rebuilt routing around this idea. Every file in app/ becomes a deep-linkable route automatically. There is no linking config to wire up, no path-to-screen mapping to maintain. If app/order/[id].tsx exists, then https://yourdomain.com/order/123 opens that screen with id="123". Add a route, get a deep link for free.

Prerequisites

  • Expo SDK 53 or newer (npx create-expo-app@latest)
  • Expo Router 4.x (installed by default in the template)
  • An EAS Build account — the iOS Associated Domains entitlement requires a real provisioning profile, not Expo Go
  • A domain you control with HTTPS (Cloudflare Pages, Vercel, or any static host works)
  • An Apple Developer Team ID and the Android SHA-256 signing fingerprint of your release key

If you can run eas build --profile development --platform ios and install the result on a physical device, you are ready. The simulator and Expo Go cannot test universal links — Apple validates the entitlement against a real device.


Step 1 — Configure your scheme and bundle identifiers

Open app.json (or app.config.ts) and lock down four values. These are the keys Apple and Google use to verify the link belongs to your app, so a typo here is the most common reason setup fails.

{
  "expo": {
    "name": "OrderApp",
    "slug": "orderapp",
    "scheme": "orderapp",
    "ios": {
      "bundleIdentifier": "com.yourco.orderapp",
      "associatedDomains": [
        "applinks:yourdomain.com",
        "applinks:www.yourdomain.com"
      ]
    },
    "android": {
      "package": "com.yourco.orderapp",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            { "scheme": "https", "host": "yourdomain.com" },
            { "scheme": "https", "host": "www.yourdomain.com" }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    },
    "plugins": ["expo-router"]
  }
}

Three things to call out. First, keep the custom scheme — it is still useful for OAuth callbacks and dev builds. Second, list every host you want to open the app, including the bare apex and the www subdomain. Third, autoVerify: true on Android is what triggers the App Links verification flow during install. Skip it and Android will show the disambiguation dialog ("open with…") every time, which kills the magic.

Step 2 — Host the verification files

Apple and Google both require you to publish a JSON file at a fixed path on your domain. Both are checked over HTTPS the moment a user installs the app, and again on first launch.

iOS — apple-app-site-association

Create a file at https://yourdomain.com/.well-known/apple-app-site-association with this content:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID123.com.yourco.orderapp"],
        "components": [
          { "/": "/order/*" },
          { "/": "/invite/*" },
          { "/": "/", "exclude": true }
        ]
      }
    ]
  }
}

Replace TEAMID123 with your Apple Developer Team ID (10 characters, found in the Apple Developer portal under Membership). The components array is your routing allowlist — any path that matches opens the app, anything else stays in Safari. The final exclude: true rule keeps your homepage in the browser so marketing pages still work.

Critical: serve this file with Content-Type: application/json and no file extension. Cloudflare Pages and Vercel get this right by default if you put the file at public/.well-known/apple-app-site-association. If you are on nginx, add an explicit location block:

location /.well-known/apple-app-site-association {
    default_type application/json;
    add_header Content-Type application/json;
}

Android — assetlinks.json

Create https://yourdomain.com/.well-known/assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.yourco.orderapp",
      "sha256_cert_fingerprints": [
        "AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89"
      ]
    }
  }
]

Get the SHA-256 fingerprint with EAS:

eas credentials  # then choose Android > production > view keystore

Or pull it directly from a built APK:

keytool -printcert -jarfile app-release.apk

If you use Google Play App Signing (recommended), add a second entry with the upload key fingerprint and the Play-managed key fingerprint. Skipping the Play key is the number one reason Android App Links work in internal testing and break in production.


Step 3 — Verify the files are reachable

Before you build the app, prove the OS can fetch your verification files. Apple runs a CDN-cached check, Google runs a live one — but both will fail silently if your hosting blocks them with a redirect, basic auth, or a Cloudflare bot rule.

# iOS — must return 200, application/json, no redirects
curl -I https://yourdomain.com/.well-known/apple-app-site-association

# Android — Google's official verifier
curl "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://yourdomain.com&relation=delegate_permission/common.handle_all_urls" 

Apple also exposes a CDN debug endpoint that shows what their crawler last fetched:

https://app-site-association.cdn-apple.com/a/v1/yourdomain.com

If the AASA file is missing or malformed there, your iOS users will see a Safari page even though everything looks correct on your origin server. Apple caches for up to 7 days, so test this before you ship a build.

Step 4 — Wire up the routes

With Expo Router this is the easy part. Your file system is the routing config. A real example — an order tracking app:

app/
  _layout.tsx          # root stack
  index.tsx            # /
  order/
    [id].tsx           # /order/123
  invite/
    [code].tsx         # /invite/abc123
  (auth)/
    login.tsx          # /login

That single directory tree gives you four deep-linkable URLs. To read the param inside the screen, use useLocalSearchParams:

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

export default function OrderScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  return (
    <View>
      <Text>Order #{id}</Text>
    </View>
  );
}

Now https://yourdomain.com/order/123 opens this screen with id="123". Same code handles the deep link, the in-app navigation, and the web build if you ship one.

Step 5 — Handle cold starts vs warm starts

There are two cases your app needs to handle, and they hit different APIs. Warm start: the app is in memory when the link arrives. Expo Router handles this for you — the route changes, the screen mounts, your params are populated. Done. Cold start: the user taps the link, the OS launches your app from scratch, and the URL has to be retrieved with a separate call.

On Expo Router 4, both cases are unified through the standard hook, but you may still need the raw URL for analytics or auth state hydration:

// app/_layout.tsx
import { Stack, useRouter } from 'expo-router';
import * as Linking from 'expo-linking';
import { useEffect } from 'react';

export default function RootLayout() {
  const router = useRouter();

  useEffect(() => {
    // Cold start: URL the app was launched with
    Linking.getInitialURL().then((url) => {
      if (url) {
        console.log('[deeplink] cold start:', url);
        // analytics, attribution, etc.
      }
    });

    // Warm start: app already running
    const sub = Linking.addEventListener('url', ({ url }) => {
      console.log('[deeplink] warm start:', url);
    });
    return () => sub.remove();
  }, []);

  return <Stack />;
}

You almost never need to call router.push manually inside the listener. Expo Router has already navigated by the time your effect fires — logging is the right job for this hook in 99% of apps.


Common pitfalls (and how to debug them)

1. AASA file served as text/plain

Expo Web defaults to application/json, but a lot of static hosts add a .json extension or set the wrong content type. The file must be served at /.well-known/apple-app-site-association with no extension and content type application/json. Test with curl -I — if you see text/html or a 301, fix the host.

2. Forgetting the Play App Signing fingerprint

If you upload to Google Play, Google re-signs your APK with their managed key. The fingerprint in assetlinks.json has to match the final signing key, not your upload key. Check Play Console → Setup → App signing for both fingerprints and add both to the array.

3. autoVerify silently failing on Android

Run adb shell pm get-app-links com.yourco.orderapp on a connected device after install. You want to see verified next to your domain. If you see none or ask, Google could not fetch assetlinks.json — usually a redirect or wrong fingerprint. Force a re-check with adb shell pm verify-app-links --re-verify com.yourco.orderapp.

4. iOS opens Safari instead of the app

Apple caches AASA for up to 7 days on the CDN, and the user's device may have an even older copy. If you changed the file recently, fully delete the app, reboot the device, then reinstall from a fresh build. There is no public way to bust Apple's cache — only time and a fresh install.

5. Long-press to open instead of direct tap

If users have to long-press the link and choose "Open in App", you have not configured Universal Links — you are still on a custom scheme. Real universal links open on a single tap, every time. The long-press menu is the giveaway.


Quick reference

WhatiOSAndroid
Verification file/.well-known/apple-app-site-association/.well-known/assetlinks.json
IdentifierTeamID + bundleIdpackage + SHA-256 fingerprint
Config in app.jsonios.associatedDomainsandroid.intentFilters + autoVerify
Test onPhysical device onlyPhysical device or emulator
Cache windowUp to 7 days (Apple CDN)Re-checked on install
Debug commandcurl app-site-association.cdn-apple.comadb shell pm get-app-links

Next steps

  • Add an attribution layer — capture the cold-start URL in your analytics tool to measure which channels drive installs
  • Build a web fallback at the same URL so users without the app land on a useful page (Expo Router web makes this free)
  • Wire up expo-notifications to handle universal links inside push payloads — the same routing works
  • If you do OAuth, keep the custom scheme for the redirect — universal links are too slow for the auth round-trip

The whole setup takes about an hour the first time and roughly five minutes on every project after that. Once it is wired up, every new screen you add to app/ is automatically a sharable URL — and that compounds into a much better product.

Comments

Subscribe to join the conversation...

Be the first to comment