Skip to content

Deep linking and universal links in React Native, end to end

· 4 min read · Amrith Vengalath

  • React Native
  • Deep Linking
  • iOS
  • Android

Deep linking is one of those features where every individual piece is documented, but nobody shows you the whole thing assembled, and the failure modes are silent. A link doesn't open the app and you get no error - it just opens the website instead, or the app launches but lands on the home screen. Here's the complete picture from the work of making it actually reliable.

  • Custom scheme (myapp://product/42): your app registers a scheme. Simple, but any app can claim a scheme, and these don't work as normal web links.
  • Universal Links (iOS) / App Links (Android) (https://myapp.com/product/42): real https URLs that open your app if it's installed, and your website if it's not. These are what you actually want for links shared in messages, emails, and on the web. They're also much more work to set up.

You generally want both: universal/app links for the real user-facing links, and a custom scheme as a fallback and for internal use.

The website half nobody mentions first

Universal and app links require you to prove you own both the app and the domain by hosting association files on your site. This is the step people skip and then wonder why links open Safari.

iOS looks for https://myapp.com/.well-known/apple-app-site-association (no file extension, served as JSON, no redirects):

{
  "applinks": {
    "details": [{
      "appID": "TEAMID.com.mycompany.myapp",
      "paths": ["/product/*", "/order/*"]
    }]
  }
}

Android looks for https://myapp.com/.well-known/assetlinks.json with your app's SHA-256 signing fingerprint. Get the fingerprint wrong - for instance, use your debug key's fingerprint but ship with the release key - and links silently fail in production while working in dev. That specific mismatch cost me a frustrating afternoon.

The native registration

On iOS you add the associated domains entitlement (applinks:myapp.com). On Android you add intent filters to the manifest with android:autoVerify="true" so the system verifies your assetlinks.json and opens links without the "open with" chooser.

This is also native config, which means: rebuild the app. Editing entitlements and reloading JS does nothing.

The JavaScript half: handle both entry paths

This is where the actual app logic lives, and it has two cases that are easy to conflate:

import { Linking } from "react-native";
 
// Case 1: app was already running (warm). A link arrives as an event.
useEffect(() => {
  const sub = Linking.addEventListener("url", ({ url }) => {
    routeFromUrl(url);
  });
  return () => sub.remove();
}, []);
 
// Case 2: app was closed (cold). The link is what launched it.
useEffect(() => {
  Linking.getInitialURL().then((url) => {
    if (url) routeFromUrl(url);
  });
}, []);

The bug everyone hits: handling only the warm case. You test with the app already open, links work, you ship. Then a user taps a link with the app closed, getInitialURL isn't wired up, and the app opens to the home screen instead of the product. Both paths are mandatory.

Parsing and routing

routeFromUrl turns the URL into a navigation action. Keep it boring and centralized:

function routeFromUrl(url) {
  const { hostname, path } = parse(url);          // a small URL parser
  const match = path.match(/^\/product\/(\w+)/);
  if (match) navigationRef.navigate("Product", { productId: match[1] });
}

I route through a navigation ref rather than a hook because deep links can arrive before any screen has mounted, especially on cold start. Trying to use navigation that isn't ready yet is another quiet failure - the link "does nothing." Queue it until the navigator is ready if you have to.

How I test it now

Because the failure modes are silent, I test deliberately, both platforms, both states:

# iOS simulator
xcrun simctl openurl booted "https://myapp.com/product/42"
 
# Android
adb shell am start -W -a android.intent.action.VIEW \
  -d "https://myapp.com/product/42" com.mycompany.myapp

And critically, I test the cold-start case by force-quitting the app first, because that's the path that's both the most common in real life and the one most likely to be broken.

The summary I keep in my head

Three layers have to agree: the website association files, the native registration, and the JavaScript that handles both warm and cold entry. If a link opens the browser instead of the app, it's almost always the association file or the signing fingerprint. If the app opens but lands on the wrong screen, it's the cold-start handler or navigation that wasn't ready. Check them in that order and you'll find it fast.