deep-linking

React Native Deep Linking: Universal Links and App Links Setup Guide

React Native deep linking takes three pieces: Universal Links on iOS, App Links on Android, and a React Navigation linking config. Set each one up end to end.

WarpLink Team··24 min read

TL;DR: React Native deep linking has three layers, and a link opens your app only when all three agree. Native iOS needs an Associated Domains entitlement and an apple-app-site-association file on your link domain. Native Android needs an intent filter with android:autoVerify="true" and an assetlinks.json carrying the SHA-256 of the key that actually signs your release, which is the Play App Signing key once you ship through the Play Console. JavaScript then reads the URL through Linking.getInitialURL() for a cold start and Linking.addEventListener('url', ...) for a warm start, and React Navigation 7 turns that URL into a screen through the linking prop's prefixes and config. The bug almost everyone hits is the cold start race: the URL arrives before the navigator mounts, so you have to hold it and replay it on onReady. Expo needs the same two native pieces declared in app.json under ios.associatedDomains and android.intentFilters, plus a development build, because Expo Go cannot verify your domain. The rest of this guide is each layer in full, then the commands to test it and the pitfalls that cost the most time.

React Native deep linking is the wiring that lets a URL open a specific screen inside your app instead of a web page, on both iOS and Android.

Before any code, get the vocabulary right, because the three link types have different failure modes and the fixes do not transfer.

Link typeLooks likeVerified by the OSWorks without the app
Custom URL schememyapp://product/42NoNo. The tap dead-ends
Universal Link (iOS)https://links.example.com/product/42Yes, through apple-app-site-associationYes. Opens the web page
App Link (Android)https://links.example.com/product/42Yes, through assetlinks.jsonYes. Opens the web page

A custom scheme is a claim, not a proof. Any app on the device can declare myapp://, and on Android the last one installed can win the chooser. It needs no server, which makes it perfect for local development and OAuth callbacks, and unusable for anything you publish, because a user without your app gets a browser error page rather than your site.

A Universal Link and an App Link are the same idea on two platforms: an ordinary https URL that the operating system has verified belongs to you, by fetching a file from your domain and comparing it to the app's signing identity. No other app can intercept it. If the app is missing, the URL is still a URL, so the browser loads your web page and you can send the user to the store from there. This is what you ship.

React Native does not change any of that. The verification happens entirely in the native layer, before a single line of JavaScript runs. What React Native gives you is a way to receive the resulting URL in JavaScript and a router that can turn it into a screen. So the work splits cleanly: two native setups, then one JavaScript setup that is identical across both platforms.

Throughout this guide the example domain is links.example.com, the example bundle identifier and package name are com.example.myapp, and the example scheme is myapp. Substitute your own.

Three pieces, all required: the entitlement, the hosted file, and the delegate hook.

The Associated Domains entitlement

In Xcode, select your app target, open Signing & Capabilities, add the Associated Domains capability, and add one entry per link domain in the form applinks:links.example.com. That writes into ios/YourApp/YourApp.entitlements:

You also have to enable the Associated Domains capability on the App ID in the Apple Developer portal and regenerate the provisioning profile, or the build will be rejected at signing time with a mismatched entitlement.

During development, append ?mode=developer to the entry (applinks:links.example.com?mode=developer) and turn on Settings > Developer > Associated Domains Development on the device. That makes iOS fetch the association file straight from your server instead of through Apple's cache, which is the difference between a two-second edit loop and a one-day one. Remove the suffix before you ship.

The apple-app-site-association file

Host this at https://links.example.com/.well-known/apple-app-site-association:

ABCDE12345 is your Apple Team ID, from the Membership page of the developer portal. The rules that trip people up, in the order they usually bite:

  • Serve it over HTTPS with a valid certificate, with no redirect at any hop. A 301 to www. is a failure, not a detour.
  • Content-Type: application/json. No .json extension on the path.
  • No authentication, no cookie wall, no bot challenge in front of it. Apple's fetcher is not a browser.
  • Order matters inside components. The first matching entry wins, so put exclude rules above the broad patterns they carve out of.
  • Keep it small. Apple caps the file at 128 KB.

iOS 13 and later use the components array, which is what every version in the iOS 17 and 18 range reads. Older paths arrays still parse, but there is no reason to write one now.

The file is fetched by Apple's content delivery service at install time and refreshed periodically, not on demand. That cache is the single most common reason a fix "does not work": the file on your server is correct and the copy on the device is not. Developer mode above bypasses it. A delete and reinstall of the app also forces a fresh fetch.

The AppDelegate hook

React Native's Linking module does not receive Universal Links on its own. RCTLinkingManager has to be called from the delegate. React Native 0.77 moved the app template to a Swift AppDelegate, so add this to ios/YourApp/AppDelegate.swift:

On React Native 0.75 and 0.76 the template is still Objective-C, and the same two methods go into the AppDelegate implementation in ios/YourApp/AppDelegate.mm, after importing <React/RCTLinkingManager.h>:

If your delegate already forwards to more than one handler, store each result in a local before you combine them. A || chain short-circuits, so a handler placed after one that returns true never runs, and that is a very quiet way to lose your OAuth callback.

The SceneDelegate case

The delegate methods above are only called in an app that uses the classic application lifecycle, which is what the stock React Native template still generates. If your app has adopted the UIScene lifecycle, which brownfield apps embedding React Native often have, UIApplicationSceneManifest is present in Info.plist and UIKit routes user activities to the scene delegate instead. Your application(_:continue:restorationHandler:) will never fire, and the symptom is a Universal Link that launches the app onto the home screen with no URL.

In that case, implement the scene equivalents and forward from there:

The cold start URL arrives in willConnectTo, not in continue, which is why both are needed. Apple has been steadily moving UIKit toward the scene lifecycle, so it is worth knowing which one your app is on before you debug anything else.

Two pieces on the device, one on the server.

The manifest

Everything lives on your launch activity in android/app/src/main/AndroidManifest.xml:

Two lines in there do more work than they look like they do.

android:launchMode="singleTask" is what makes warm start work. Without it, Android spawns a fresh activity instance for each incoming link instead of delivering the intent to the running one through onNewIntent, and React Native's url event never fires for a user who already had the app open.

Keeping the custom scheme in a separate intent filter is not stylistic. An autoVerify filter is verified as a unit, and Android's verifier only knows how to verify http and https data elements. Put myapp in the same block and verification for the whole filter fails, which takes your https App Links down with it.

The assetlinks.json file

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

Same serving rules as the iOS file: HTTPS, no redirects, application/json, publicly reachable. Android's verifier is stricter about redirects than most people expect and will not follow one.

The fingerprint is where App Links go wrong most often. It must be the SHA-256 of the certificate that signs the APK the user actually installs. Once you upload to the Play Console, Google re-signs your app with the Play App Signing key, so the fingerprint of your local upload keystore is not the one on the device. Get the right value from the Play Console under your app's release setup, in the app signing section, where the app signing key certificate SHA-256 is listed. Copy that one.

The practical answer is to list several fingerprints in the array, because the field accepts a list:

  • The app signing key from the Play Console, for store and internal-testing builds.
  • The upload key from the same page, for builds you sideload before uploading.
  • Your local debug key, for day-to-day development:

Every fingerprint in the array is accepted, so one file covers debug, internal testing, and production.

Verification behaviour by Android version

Verification runs at install time and needs network access. On Android 12 and newer (API 31 and up), the rules tightened: an unverified https link no longer prompts with a chooser, it just opens in the browser, silently. That silence is why so many teams believe App Links "stopped working" on newer devices when in fact verification failed and nothing said so. The behaviour is the same through Android 13, 14, and 15.

The user-facing escape hatch is Settings > Apps > your app > Open by default, where a user can add your domain by hand. It is a debugging aid, not a shipping strategy.

Step 3: Receive the URL in JavaScript

Once the native layers verify, both platforms deliver the URL to the same two JavaScript APIs. Linking.getInitialURL() returns the URL that launched the process, or null for a normal launch. The url event fires when a link reaches an app that is already running.

Two details worth knowing. addEventListener returns a subscription object with a remove() method; the old Linking.removeEventListener was removed years ago and calling it now throws. And getInitialURL() keeps resolving with the same launch URL for the life of the process, so if you call it again after the user has navigated away you get the stale value, not the current screen.

You will notice this hook does no parsing. That is deliberate. React Native ships an incomplete URL implementation, so new URL(url).pathname is not something to lean on. Let the router do the parsing.

Step 4: Route With React Navigation 7

React Navigation can own the whole flow. Give it the prefixes it should claim and a map from paths to screens, and it will call getInitialURL and subscribe to the url event for you.

prefixes is a claim list, not a security boundary. A URL whose start matches one of these entries is stripped down to a path and matched against config.screens. Anything else is ignored. The NotFound: '*' catch-all is worth adding early: without it, an unmatched path silently does nothing, and "silently does nothing" is the hardest deep link bug to diagnose.

Wire it into the container:

The fallback element renders while the initial URL is being resolved. Skip it and the user sees the home screen flash before the deep-linked screen replaces it, which reads as a bug even though it is not.

Two things are new in React Navigation 7. linking.enabled accepts 'auto', which derives paths from screen names so a simple app can drop the explicit config. And on the static API with createStaticNavigation, the per-screen linking option lives in the screen definitions and the container only needs prefixes. Everything else here applies to both forms.

Step 5: Win the Cold Start Race

When React Navigation owns the linking config, it handles cold start itself. The race appears the moment a URL reaches you from somewhere else: a native module callback, a push notification payload, or an SDK that resolves a short link into a destination.

The shape of the bug is always the same. The URL arrives during the first render pass. Your handler calls navigationRef.navigate(...). The container has not mounted yet, so the ref is not ready, the call is dropped, and the app sits on the home screen. It works perfectly in development, where the app is usually already running when you test the link.

The fix is a one-slot queue.

Then drain the queue from onReady:

getStateFromPath reuses the exact config your router already has, so a link routed this way lands on the same screen with the same params as one routed by the linking prop. resetRoot rather than navigate gives you the full stack the config describes, which means the back button behaves the way it would if the user had walked there.

The string handling in pathFromUrl is deliberate: React Native's URL is not the browser one, and its pathname is not reliable across versions.

Step 6: Configure Expo Projects

Expo needs the same two native declarations. It just writes them for you from app.json during prebuild.

Then regenerate the native projects:

expo-linking is the Expo-flavoured wrapper over the same native plumbing:

Three constraints to plan around.

Expo Go cannot do verified links. It runs under Expo's own bundle identifier and package name, so your association files can never match it. Custom schemes in Expo Go resolve through exp:// rather than your scheme. Universal Links and App Links need a development build (npx expo prebuild, or an EAS development profile) or a store build.

Prebuild regenerates native files. Anything you hand-edit in ios/ or android/ is replaced on the next prebuild unless you either commit the generated directories and stop running prebuild, or move the edit into a config plugin. For the linking setup above there is nothing to hand-edit, since app.json covers both platforms, but the moment a native SDK asks you to add a line to the AppDelegate, that is the decision in front of you.

Expo Router derives its own config. If you use Expo Router, the file-based routes are the linking config. You do not write prefixes and config; you make sure expo.scheme and the two native declarations above are correct, and the router matches paths to files.

Step 7: Test Every Path

Test five things, per platform: custom scheme, verified https link, cold start, warm start, and the unmatched path that should land on your not-found screen.

iOS

The header check is the one people skip and the one that finds the bug. You are looking for HTTP/2 200, content-type: application/json, and no location header anywhere in the chain.

For verified Universal Links, use a physical device. The simulator does not exercise the association service the way a device does, and a green result there proves less than it appears to. On device, the reliable test is a real tap: paste the link into Notes or Messages, then tap it. Typing it into the Safari address bar does not trigger a Universal Link, by design. If it opens the browser instead of the app, connect the device to a Mac and watch the swcd process in Console for the association fetch and its result.

Android

The distinction between the first two commands matters more than any other line in this section. Adding the package name at the end targets your app directly and bypasses verification entirely, so it always works and tells you nothing about App Links. Leave the package off and you are testing what a real user gets. pm get-app-links prints each host with its state; verified is the only one that counts, and none or 1024 means the fetch or the fingerprint comparison failed.

Pitfalls That Cost the Most Time

  • The fingerprint is from the wrong key. Play App Signing re-signs your app, so the upload keystore fingerprint is not the one on the device. List the app signing key, the upload key, and your debug key together.
  • A redirect in front of an association file. Both platforms refuse to follow one. An apex-to-www redirect, a trailing-slash normalizer, or a country redirect breaks verification with no visible error.
  • Universal Links do not fire from redirects. iOS only routes a link into an app on a genuine user tap. A server 302 or a window.location assignment lands in the browser every time, which is why a tracking redirector in front of your link domain quietly disables Universal Links.
  • The user taught iOS to prefer Safari. Tapping the small breadcrumb banner at the top right of the page after a Universal Link opens makes iOS remember the browser for that domain. Recover it by long-pressing the link and choosing Open in "YourApp", not by reinstalling.
  • A custom scheme typed into Android's address bar. Chrome will not navigate to myapp:// from the omnibox. For a browser page that needs to open the app, use an intent:// URL with a browser_fallback_url, or a verified https link.
  • autoVerify on a filter that includes a custom scheme. The whole filter fails verification. Separate blocks, always.
  • Missing launchMode="singleTask". Cold start works, warm start does nothing, and the difference is invisible until a user with the app open taps a link.
  • Testing warm start after a cold start. getInitialURL() returns the same launch URL for the process lifetime. If your handler reads it on every foreground, you will replay the first link forever.
  • Assuming the simulator is representative. Universal Links and App Link verification both depend on network fetches the simulator handles differently. Verify on hardware before you believe a result.
  • Forgetting the store fallback. A verified link on a device without the app is just a web page. Whatever that page does next, sending the user to the right store listing and preserving the destination, is your responsibility, not the OS's.

Frequently Asked Questions

How do I set up deep linking in React Native? In three layers. Native iOS needs an Associated Domains entitlement plus an apple-app-site-association file on your link domain, native Android needs an intent filter with android:autoVerify="true" plus an assetlinks.json carrying the SHA-256 of your release signing key, and JavaScript reads the incoming URL with Linking.getInitialURL() and Linking.addEventListener('url', ...). React Navigation then maps that URL to a screen through the linking prop. A link only opens your app when all three layers agree.

What is the difference between a custom URL scheme, a Universal Link, and an App Link? A custom scheme such as myapp:// is claimed by any app that declares it, needs no domain verification, and shows an error page when the app is missing. A Universal Link on iOS and an App Link on Android are ordinary https URLs that the OS verifies against a file hosted on your domain, so no other app can claim them and the URL still works in a browser when the app is not installed. Use https links in anything you publish, and keep the custom scheme for local development and OAuth callbacks.

Why does my React Native deep link work on warm start but not cold start? Because the URL arrives before the navigation container has mounted. Linking.getInitialURL() resolves during the first render pass, so a navigate call made from it hits a navigator that is not ready yet and is silently dropped. Hold the URL in a module-level variable and replay it from the container's onReady callback, or let React Navigation own the link by passing the linking prop instead of routing by hand.

Does deep linking work with Expo? Yes, with a development build. Declare expo.ios.associatedDomains and expo.android.intentFilters in app.json, run npx expo prebuild, and use expo-linking to build and parse URLs. Expo Go cannot verify your domain because it runs under its own bundle identifier and package name, so Universal Links and App Links only resolve in a development build or a store build.

How do I test React Native deep links on iOS and Android? Use xcrun simctl openurl on an iOS simulator and adb shell am start with the VIEW action on Android for custom schemes and for routing logic. Verified https links need more care: check the hosted files with curl, confirm Android verification with adb shell pm get-app-links, and test the real tap on a physical iOS device, since the simulator does not exercise Apple's association CDN the way a device does.

Why do my Universal Links open Safari instead of my app? Usually one of four causes: the association file is missing, redirected, or served with the wrong content type; the entitlement lists a different domain than the link; the user tapped the breadcrumb banner in Safari, which makes iOS remember the browser for that domain; or the link was reached through a server or JavaScript redirect rather than a direct tap. Universal Links only fire on a genuine user tap.

Everything above is the platform behaviour, and it is the same whoever hosts your links. What changes is how much of it you maintain by hand. Two of the seven steps are pure server work with no product value: keeping an apple-app-site-association file and an assetlinks.json correct, redirect-free, and in sync with every signing key and bundle identifier change. Those are the two files that break silently and take a release cycle to notice.

WarpLink generates and hosts both from your app registration. Enter the bundle ID and Team ID for iOS and the package name and SHA-256 fingerprints for Android, and the AASA and assetlinks.json are built and served on your link domain, then regenerated whenever the app configuration changes. On the device, links resolve at the edge in under 10 milliseconds, so the tap-to-screen path stays short whichever country the user is in.

On the JavaScript side, the whole receive-and-route layer collapses into one callback. configure() wires cold start, warm start, and the first-launch deferred check into a single onLink sink:

The two native host hooks from Steps 1 and 2 still apply, with one addition each: the iOS AppDelegate forwards each incoming URL to WarpLinkModule.handleIncomingURL(url) alongside your existing linking call, and the Android activity keeps android:launchMode="singleTask". Declaring your custom domain in linkDomains is what lets a link on it resolve on the very first launch, before the SDK has fetched anything.

The WarpLinkDeepLink handed to onLink carries linkId, destination, deepLinkUrl, and customParams, so the original campaign context arrives with the route. It also carries isDeferred, matchType, matchConfidence, and matchGuaranteed, which is where linking turns into attribution: the same callback that puts the user on the right screen tells you which link, campaign, and channel brought them, and those installs show up in real-time analytics next to the clicks that produced them. Prefer manual control and every piece is still exposed on its own: getInitialDeepLink(), onDeepLink(), handleDeepLink(), checkDeferredDeepLink(), and getAttributionResult(). The SDK is MIT licensed with no third-party runtime dependencies.

Create a free WarpLink account for deep linking, install attribution, and real-time analytics in one SDK, with 10,000 clicks a month on the free tier. The React Native SDK guide has the full setup.

WarpLink Team

Building affordable, reliable link infrastructure for mobile teams. Deep linking, install attribution, and real-time analytics in one SDK.

Related Posts