← Back to Blog

React Native and Expo Deep Linking Without an SDK: Universal Links, App Links and Install Attribution

React Native and Expo deep linking needs OS wiring either way - AASA, assetlinks.json, intent filters. What you can skip is the vendor SDK: two HTTPS calls.

Quick answer: Deep linking in React Native and Expo runs on the same OS machinery as any native app - an Associated Domains entitlement and a hosted apple-app-site-association file on iOS, an autoVerify intent filter and assetlinks.json on Android. None of that disappears, whatever tooling you choose; Expo just generates it from your app.json. What you can skip is embedding an attribution vendor's SDK: React Native's built-in Linking module (or React Navigation's linking prop) delivers incoming links to your JavaScript, and install attribution plus deferred deep linking work over two HTTPS calls - nothing vendor-specific in your binary. This guide covers the full setup for both Expo and bare React Native, and where an install-attribution layer fits without an SDK. Everything below is dated; framework details move fast.

$ cat table-of-contents

  1. The four layers of React Native deep linking
  2. Which link type do you actually want?
  3. iOS setup: entitlement and the AASA file
  4. Android setup: intent filters and assetlinks.json
  5. Getting the URL into your JavaScript
  6. Expo specifics: app.json, Expo Go and dev builds
  7. Install attribution without an SDK
  8. Testing each layer
  9. Frequently asked questions
  10. If you remember three things
  11. Sources

The four layers of React Native deep linking

Most React Native deep linking guides mix four separate jobs into one checklist, which is why so many developers debug the wrong layer. Keep them apart:

  1. OS verification. iOS and Android only open your app for an HTTPS link when your domain proves you own both the domain and the app: the AASA file on iOS, Digital Asset Links (assetlinks.json) on Android. This layer is unavoidable - no framework, package or vendor removes it.
  2. Native project config. The Associated Domains entitlement on iOS and the intent filter in AndroidManifest.xml on Android. In an Expo managed project you declare these in app.json and the build generates the native files; in a bare React Native project you edit Xcode and the manifest yourself. Either way, the end state is identical.
  3. Delivery. Getting the incoming URL from the OS into your JavaScript: React Native's built-in Linking module, or React Navigation's linking prop which wraps it. No third-party SDK needed - this is core platform API.
  4. Routing. Turning the URL into the right screen: React Navigation's config maps paths to screens.

Attribution vendors historically bundled layers 3-4 into their SDK along with install tracking, which is how "deep linking" came to mean "install an SDK". It doesn't. Layers 3 and 4 are a built-in module and a config object. The only thing left to solve is the attribution itself - which, as we'll see, is two HTTPS calls.

Link type Looks like Verified by the OS? Falls back to a website?
Custom URL scheme myapp://product/42 No - any app can register the same scheme No - dead end if the app is not installed
Universal Link (iOS) https://links.example.com/abc Yes - AASA file on your domain Yes - opens the URL in the browser
App Link (Android) https://links.example.com/abc Yes - assetlinks.json on your domain Yes - opens the URL in the browser

Ship Universal Links and App Links. A custom scheme is fine for local development, but it is an unverified claim - another app can register the same scheme and intercept your links - and it leaves non-installed users stranded. HTTPS links degrade gracefully to your website or store, and they are the only kind an install-attribution layer can work with across the install gap. The mechanics of that gap - the user clicks, installs, and the context must survive the store trip - are covered in the deferred deep links implementation guide.

iOS setup: entitlement and the AASA file

The Associated Domains entitlement

Expo managed: declare it in app.json and the next build writes the entitlement for you:

app.json (Expo)

{
  "expo": {
    "scheme": "myapp",
    "ios": {
      "associatedDomains": ["applinks:links.example.com"],
      "entitlements": {
        "com.apple.developer.associated-domains": ["applinks:links.example.com"]
      }
    }
  }
}

Bare React Native: open the project (ios/MyApp.xcworkspace) in Xcode, select your target, open Signing & Capabilities, add Associated Domains, and add one entry per link host: applinks:links.example.com. Xcode writes it to your entitlements file. Enable the same capability on your App ID in the Apple Developer portal and regenerate the provisioning profile if needed - a mismatched entitlement fails at signing time.

The apple-app-site-association file

Apple verifies the entitlement against a file your domain serves at https://links.example.com/.well-known/apple-app-site-association (no extension, JSON content type, valid HTTPS, no redirects):

apple-app-site-association

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appIDs": ["ABCDE12345.com.example.myapp"],
        "paths": ["*"]
      }
    ]
  }
}

ABCDE12345 is your Apple Team ID; the rest is your bundle identifier. iOS fetches this file through Apple's CDN at install and app-update time, so a fix you deploy can take time to reach devices - the file is cached by Apple, not fetched live per tap. Two things this means in practice: test on a fresh install after changing the file, and never serve it behind a redirect. Apple's Associated Domains documentation covers the entitlement and file format.

Android setup: intent filters and assetlinks.json

The intent filter

Expo managed: declare it in app.json:

app.json (Expo)

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

Bare React Native: edit android/app/src/main/AndroidManifest.xml and add the filter inside your MainActivity:

AndroidManifest.xml

<activity android:name=".MainActivity" android:launchMode="singleTask">
  <intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" />
    <data android:host="links.example.com" />
  </intent-filter>
</activity>

android:autoVerify="true" is what upgrades a plain deep link into an App Link: it tells Android to verify the domain association at install instead of showing the disambiguation dialog. Keep launchMode="singleTask" on the activity - it is the default, and React Navigation's docs call it out because a changed launch mode is a common reason warm links open a second copy of the app instead of routing into the running one.

The assetlinks.json file

Your domain serves https://links.example.com/.well-known/assetlinks.json:

assetlinks.json

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.myapp",
    "sha256_cert_fingerprints": ["SHA256: OF THE: KEY: THAT: SIGNS: YOUR: RELEASE"]
  }
}]

The fingerprint must match the key that actually signs the build your users install. If you ship through Google Play with Play App Signing (the default for new apps), that is the Play App Signing key, not your upload key - copy the SHA-256 from Play Console, under Setup > App Integrity. A fingerprint from your local debug keystore will verify in your debug build and fail for every store install; this is one of the most common App Links bugs. Google's App Links training covers verification and the Digital Asset Links protocol.

Getting the URL into your JavaScript

React Native ships a built-in Linking module, and it is all you need for delivery. It answers two questions:

  • Cold start - the app was not running and the link launched it: Linking.getInitialURL() returns the URL that opened the app.
  • Warm start - the app is already open and a new link arrives: Linking.addEventListener('url', handler) fires with the URL.

Bare React Native apps also need two small native bridges so the OS hands the URL to the module: an RCTLinkingManager call in application:openURL:options: (and continueUserActivity: for Universal Links) in the AppDelegate, and the intent filter above on Android. Expo generates these for you. React Navigation's deep linking guide lists the exact AppDelegate snippets for both Swift and Objective-C.

If you use React Navigation (7.x), skip wiring Linking yourself and give the container a linking prop - it subscribes to the module, resolves the cold-start URL for you, and maps paths to screens:

React Navigation linking config

import { NavigationContainer } from '@react-navigation/native';

const linking = {
  prefixes: ['myapp://', 'https://links.example.com'],
  config: {
    screens: {
      Home: '',
      Product: 'product/:id',
      Referral: 'r/:code',
    },
  },
};

export default function App() {
  return (
    <NavigationContainer linking={linking} fallback={<LoadingScreen />}>
      {/* navigators */}
    </NavigationContainer>
  );
}

Three details that save debugging time:

  • Define the linking object at module level (or memoize it) - React Navigation caches the processed config, and a fresh object every render defeats that.
  • The fallback prop exists because of the cold-start race: the URL arrives before the navigator mounts. React Navigation resolves it for you; the fallback is what renders while it does. WarpLink's React Native guide (updated 26 October 2026) calls this race "the bug almost everyone hits" - with the linking prop, it is handled rather than something you paper over with timeouts.
  • Their docs recommend the linking prop over handling URLs manually with a navigation ref, which is error-prone. The same guide covers the filter option for ignoring links you do not want routed (for example auth-session callbacks).

Expo specifics: app.json, Expo Go and dev builds

Expo collapses most of the native work into app.json, but three Expo-specific behaviours trip people up:

  1. Expo Go is not a real test target for incoming links. Expo's own linking guide says support for incoming links in Expo Go is limited and recommends development builds (expo-dev-client) for testing linking strategies. A link that fails in Expo Go may work fine in a dev build or standalone binary - and vice versa, an exp:// link that opens Expo Go proves nothing about your production setup.
  2. The scheme in app.json only applies to standalone apps. Inside Expo Go, deep links use the development URL (exp://ADDRESS:PORT/--/). This is why React Navigation's docs tell Expo users to build the prefix with Linking.createURL('/') from expo-linking instead of hardcoding a scheme - it returns the right prefix for the environment the code is running in.
  3. app.json changes need a rebuild to reach native config. associatedDomains and intentFilters are baked into the binary at build time (EAS Build or npx expo prebuild + local build). Editing app.json and reloading the JS bundle does nothing to the entitlement or manifest.

For the JS side, expo-linking (version 58.0.16 at the time of writing, matching SDK 58) extends the React Native Linking API with createURL and hooks like useURL(), which returns the initial URL and then tracks subsequent ones. With React Navigation's linking prop you rarely need it beyond building the prefix:

Expo prefix

import * as Linking from 'expo-linking';

const linking = {
  prefixes: [Linking.createURL('/'), 'https://links.example.com'],
  config: { /* screens */ },
};

Install attribution without an SDK

Everything so far opens the app for people who already have it. Install attribution answers a different question: someone tapped this link, did not have the app, installed it - which link brought them? The click and the first launch are separated by an app-store trip, and the OS deletes the context in between.

The SDK-free pattern is a server that remembers. When you share a link, you share a short link issued by the attribution service, carrying whatever source and campaign you attach at creation time. One HTTPS call creates it:

Create a tracked link (server side)

curl -s -X POST https://api.linktrace.in/api/v1/referral-links \
  -H "x-api-key: $LT_API_KEY" \
  -d '{"referrerIdentifier":"twitter_launch","source":"twitter","campaign":"launch"}'

That link redirects to the right store and remembers the click. On the app's first launch after install, one more call from your JavaScript resolves it:

Resolve the install on first launch

const res = await fetch('https://api.linktrace.in/api/v1/attributions', {
  method: 'POST',
  headers: { 'x-api-key': LT_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ device: { /* platform identifiers */ } }),
});
const { data } = await res.json();
// data.attributed, data.referrerIdentifier, data.campaign

No native dependency, no build-phase script, no privacy-manifest surprises from a bundled SDK - the entire integration is two HTTPS calls. The same mechanism powers deferred deep linking: because the server knows which link the new installer tapped, it can hand back the destination (product/42, r/jane) with the attribution, and your router can land the first session on that screen instead of the home screen. See deferred deep links vs deep links for when each behaviour is the right one.

This is the layer LinkTrace (this site) provides: link creation and install resolution over HTTPS, with per-link click and install counts in the dashboard. It composes with the setup above rather than replacing it - the AASA file, assetlinks.json, intent filters and linking config are yours either way, with or without an attribution layer. On the Scale plan LinkTrace can also host the domain-side verification files on your branded subdomain, but the app-side entitlement and manifest remain in your project (checked against our pricing page, 6 November 2026). The free Starter plan includes 100 dynamic links and 1,000 attributed installs (one-time), enough to ship this end to end.

Testing each layer

Test the layers separately, or you will debug the wrong one. Layer 1-2 (OS verification) on a simulator/emulator or device with a build that has the entitlement and filter:

Layer 1-2: does the OS verify the link?

# iOS simulator
xcrun simctl openurl booted "https://links.example.com/abc"

# Android emulator
adb shell am start -a android.intent.action.VIEW \
  -d "https://links.example.com/abc" com.example.myapp

If the browser opens instead of the app, the failure is in verification (AASA/assetlinks reachability, entitlement, fingerprint), not in JavaScript. If the app opens but lands on the home screen, the failure is in delivery or routing (layers 3-4) - check your prefixes and config.screens mapping. The npx uri-scheme open "myapp://product/42" --ios helper from React Navigation's docs is handy for quick scheme checks on both platforms, but remember it tests the scheme path, not HTTPS verification. And if a fix "does nothing" on iOS, reinstall the app - the AASA file is re-fetched on install/update, not on every tap. For a fuller diagnostic walkthrough, see debugging Universal Links and App Links.

Frequently asked questions

Can I test deep links in Expo Go?

Only partially. Expo's documentation says support for incoming links in Expo Go is limited and recommends development builds for testing linking strategies. Scheme links via the exp:// development URL work for quick iteration, but Universal Links and App Links should be verified in a development build or standalone binary - that is the environment your users run.

Do I need an SDK from AppsFlyer, Branch or Adjust for deep linking?

No. Deep linking itself - opening the app to the right screen from a link - is OS machinery (AASA, assetlinks.json, intent filters) plus React Native's built-in Linking module or React Navigation's linking prop. What those SDKs add is install attribution across the store gap, and that is available as plain HTTPS calls: create a tracked link server-side, resolve it on first launch from JavaScript. You keep your binary free of vendor code.

Universal Links work on my test device but not for users - why?

The usual suspects, in order: the AASA file changed but Apple's CDN is still serving the cached copy (iOS re-fetches on install/update, so test on a fresh install); the file redirects or has the wrong content type; or the entitlement in the shipped build does not match the domain. On Android the equivalent is the assetlinks.json fingerprint not matching the Play App Signing key. Our Universal Links vs App Links comparison covers the verification models on both sides.

Should I use React Navigation's linking prop or handle URLs myself?

Use the linking prop. React Navigation's documentation explicitly recommends it over manual ref-based handling, which is error-prone. It resolves the cold-start URL (the race condition that bites hand-rolled setups), subscribes to warm links, and maps paths to screens through config. Reach for Linking.getInitialURL() directly only for links you deliberately keep out of navigation, via the filter option.

What about Firebase Dynamic Links?

Firebase Dynamic Links is shut down, and it was exactly the SDK-coupled model this post avoids. If you are migrating off it, the target architecture is the one described here: first-party Universal Links/App Links plus a link-level attribution layer. See Firebase Dynamic Links alternatives compared and how to track app installs without Firebase or AppsFlyer.

Does deferred deep linking work without an SDK?

Yes - deferred deep linking is attribution plus a destination. The first-launch resolve call returns the link the new installer tapped, including its path and any payload you attached at creation, so your router can land them on the linked screen on the very first session. No on-device vendor component is involved; the state lives server-side between the click and the first launch.

If you remember three things

  • The OS wiring is yours to do either way. AASA, assetlinks.json, the entitlement and the intent filter exist with or without any vendor - Expo just generates them from app.json. No SDK removes this layer.
  • Delivery and routing are built in. React Native's Linking module and React Navigation's linking prop handle cold and warm links, including the cold-start race. Expo users: test in a development build, not Expo Go.
  • Attribution is two HTTPS calls. Create a tracked link server-side, resolve it on first launch from JavaScript. That is install attribution and deferred deep linking without anything vendor-specific in your binary.

Sources

Framework and platform claims were checked on 6 November 2026 against the sources below. Version numbers date-stamp the behaviour; check the current docs before quoting.


LinkTrace is a deep linking and install attribution service for startups and app developers. Two HTTPS calls, flat pricing, no SDK. Create a free account or see the plans.

Related guides: Universal Links vs App Links, Debug Universal Links and App Links, Deferred Deep Links Implementation Guide, Alternatives for Firebase Dynamic Links, and Track App Installs Without Firebase or AppsFlyer