All articles
Mobile DevelopmentAugust 14, 2026 6 min read

Deep Linking in React Native 2026: Universal Links, App Links, and the Edge Cases That Break Onboarding

Deep links look trivial until a marketing campaign melts down because iOS opens Safari instead of your app. Here's how we wire universal links, App Links, and Expo Router without the usual footguns.

Deep links are the plumbing nobody thinks about until a Black Friday campaign sends 40,000 users to Safari instead of the app. In our experience, more onboarding funnels are broken by deep link misconfiguration than by any bug in the actual signup flow. This is the guide we wish existed the last three times we shipped a React Native app with paid acquisition attached.

Why deep linking still hurts in 2026

The theory is simple: a user taps https://acme.app/product/123 and lands inside your app on the product screen. The reality involves Apple's apple-app-site-association (AASA), Google's Digital Asset Links, Expo Router, three intent filters, a Branch or AppsFlyer SDK, and at least one PM asking why the link "works on my phone but not on WhatsApp".

The reason it still hurts is that each platform quietly changed the rules over the last two years:

  • iOS 17+ hardened universal link validation and made applinks entitlement mismatches fail silently.
  • Android 13+ requires verified App Links for automatic handling; unverified domains fall back to a chooser or the browser.
  • Expo Router 3 changed how the linking config is derived from the file system, so old Linking.getInitialURL patterns can double-fire.
  • Meta and TikTok in-app browsers still strip or rewrite URLs in ways that break attribution.

If you're building a new app, you need to make decisions about all four before you ship, not after.

The two link types you actually need

Stop calling everything a "deep link". There are two categories, and confusing them is where most bugs come from.

Universal / App Links (HTTPS URLs)

These are real https:// URLs that also open your app. They require domain verification via AASA on iOS and assetlinks.json on Android. They're what you want for:

  • Email campaigns
  • SMS
  • Social sharing
  • Web-to-app handoffs

They degrade gracefully: if the app isn't installed, the browser opens the same URL and you can render a web fallback or a Smart App Banner.

Custom scheme links (acme://)

These are for internal use — OAuth callbacks, push notification payloads, QR codes in controlled environments. Do not send acme://product/123 in a marketing email. It will do nothing for users without the app installed, and half of email clients will refuse to render the link at all.

Rule of thumb: if a human sees the URL, use HTTPS. If only your code sees it, custom scheme is fine.

Wiring it up with Expo Router

Expo Router derives the linking config from your file structure, which removes a whole class of "screen not found" bugs. But you still need to declare the prefixes and configure the native side.

In app.json:

{
  "expo": {
    "scheme": "acme",
    "ios": {
      "bundleIdentifier": "app.acme.ios",
      "associatedDomains": [
        "applinks:acme.app",
        "applinks:www.acme.app"
      ]
    },
    "android": {
      "package": "app.acme.android",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            { "scheme": "https", "host": "acme.app" },
            { "scheme": "https", "host": "www.acme.app" }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

Then in your root layout, if you need custom handling on top of Expo Router's automatic routing:

import { useURL } from 'expo-linking';
import { useEffect } from 'react';
import { router } from 'expo-router';

export default function RootLayout() {
  const url = useURL();

  useEffect(() => {
    if (!url) return;
    // Only intercept if you need to transform the URL,
    // e.g. strip attribution params before routing.
    const parsed = new URL(url);
    if (parsed.searchParams.has('utm_source')) {
      trackCampaign(parsed.searchParams);
    }
  }, [url]);

  return <Slot />;
}

Don't manually call router.push from the URL listener when using Expo Router — the router already handles routing based on the path. Doing both causes double navigation and back-stack corruption.

Hosting AASA and assetlinks.json correctly

This is where 70% of "why doesn't it work in production" tickets originate.

iOS: apple-app-site-association

Host at https://acme.app/.well-known/apple-app-site-association. Requirements Apple will not negotiate on:

  • Served over HTTPS with a valid cert (no self-signed, no expired)
  • Content-Type: application/json
  • No redirects — a 301 to www will fail validation
  • File size under 128KB
  • No .json extension in the URL

A minimal AASA:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.app.acme.ios"],
        "components": [
          { "/": "/product/*", "comment": "Product pages" },
          { "/": "/invite/*", "comment": "Invite links" },
          { "/": "/auth/*", "exclude": true }
        ]
      }
    ]
  }
}

Excluding /auth/* matters: OAuth callback URLs should stay in the browser so the redirect completes cleanly.

Android: assetlinks.json

Host at https://acme.app/.well-known/assetlinks.json. The SHA-256 fingerprints must include both your upload key and the Play App Signing key — forgetting the latter is the classic "works in internal testing, breaks in production" bug.

Grab both from Play Console → Setup → App signing, and list them:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "app.acme.android",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:...",
        "11:22:33:..."
      ]
    }
  }
]

Verify with adb shell pm get-app-links app.acme.android after install. If any domain shows legacy_failure or verified: false, App Links won't auto-open.

Deferred deep linking, or: the install gap

The hardest case: a user taps a link, doesn't have the app, installs from the store, opens the app fresh — and you want to route them to the original destination.

Apple and Google don't give you this for free. Your options:

  1. A third-party SDK like Branch, AppsFlyer, or Adjust. They fingerprint the install and replay the original URL on first launch. Trade-off: SDK weight, privacy review, and increasing accuracy issues on iOS due to ATT.
  2. A one-time code in the URL, shown on the web fallback, that the user pastes or that you read from clipboard on first launch. Ugly, but works and requires no SDK.
  3. Play Install Referrer (Android only) — Google actually gives you the referring URL through the Play Store install. Underused and free.

For most apps we build, we start with option 2 for the MVP because it's free and testable, then add Branch only if paid acquisition demands attribution.

The in-app browser problem

Meta, TikTok, and LinkedIn open links in their own in-app browsers. That WebView often:

  • Ignores universal link handoff (iOS won't offer to open your app)
  • Strips query parameters your attribution depends on
  • Rewrites URLs through a tracking proxy

Mitigations:

  • Detect the in-app browser via User-Agent on your web fallback and show a "Open in Safari/Chrome" prompt.
  • Encode critical parameters in the path, not the query string, since path segments survive rewriting more reliably.
  • For iOS Meta browsers specifically, a tap on your Smart App Banner still works when a normal universal link doesn't.

Testing without losing your mind

Build a testing checklist and automate what you can. At minimum:

  • Cold start from link (app killed)
  • Warm start (app in background)
  • Foreground (app already open on another screen)
  • App not installed → store → first open
  • Link inside Gmail, WhatsApp, Instagram DMs, iMessage
  • Link with UTM params
  • Link to a route requiring auth (should defer navigation until login completes)

On iOS, xcrun simctl openurl booted https://acme.app/product/123 triggers a universal link in the simulator. On Android, adb shell am start -a android.intent.action.VIEW -d "https://acme.app/product/123" does the equivalent.

For CI, we've had good results wiring a Detox test that fires a URL and asserts the resulting screen — it's not exhaustive, but it catches regressions from route renames.

What we'd do on a new project

If you're starting a React Native app today and know deep linking will matter:

  1. Register your HTTPS domain and host AASA + assetlinks.json before you write any app code. This forces the ops conversation early.
  2. Use Expo Router and let it derive the linking config. Only add custom Linking handlers for cross-cutting concerns like attribution.
  3. Reserve /auth/* for OAuth and exclude it from universal link matching.
  4. Ship an auth-gated route test in CI that fires a deep link and asserts the post-login landing screen.
  5. Skip Branch until a marketing team actually asks for attribution. It's easier to add later than to rip out.

If you'd rather hand the messy parts to someone who's shipped this a dozen times, that's what our mobile team does. Otherwise, the tools are all there in 2026 — you just have to respect the platform rules and stop pretending acme:// is a real link.

#React Native#Expo#Deep Linking#iOS#Android#Mobile Development

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project