Skip to content
Tolinku
Tolinku
Sign In Start Free
Engineering · · 6 min read

Universal Links in React Native: Complete Guide

By Tolinku Staff
|
Tolinku webhooks integrations dashboard screenshot for engineering blog posts

Universal Links are Apple's mechanism for opening your app when a user taps an HTTPS link to your domain. Unlike custom URL schemes, Universal Links are secure (only you can claim your domain), reliable (they fall back to the web if the app isn't installed), and don't show a disambiguation dialog.

This guide focuses on the iOS-specific setup for Universal Links in React Native. For the full cross-platform setup (including Android App Links), see React Native Deep Linking: Complete Setup Tutorial. For Universal Links fundamentals, see Universal Links: Everything You Need to Know.

The flow when a user taps a Universal Link:

  1. User taps an HTTPS link (in Safari, Mail, Messages, or another app)
  2. iOS checks its local cache to see if any installed app has claimed this domain
  3. If the app is installed and claims the domain, iOS opens the app and passes the URL
  4. If the app is not installed, iOS opens the URL in Safari (normal web behavior)

The "claim" happens through two pieces:

  • Your app declares which domains it handles (Associated Domains entitlement)
  • Your domain declares which apps can handle it (AASA file)

Step 1: Apple App Site Association (AASA) File

Your web server (or deep linking platform) must serve a JSON file at:

https://go.yourapp.com/.well-known/apple-app-site-association

The file content:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appIDs": ["TEAMID.com.yourapp.bundleid"],
        "components": [
          { "/": "/product/*" },
          { "/": "/invite/*" },
          { "/": "/promo/*" }
        ]
      }
    ]
  }
}

If using Tolinku, the AASA file is served automatically when you configure your Appspace with your Team ID and Bundle ID.

AASA Requirements

  • Must be served over HTTPS (no HTTP, no redirects)
  • Must have Content-Type: application/json
  • Must be accessible without authentication
  • Must not exceed 128 KB
  • Must not be behind a redirect (Apple's crawler won't follow redirects)

Verify the AASA

# Check accessibility
curl -I https://go.yourapp.com/.well-known/apple-app-site-association

# Check content
curl https://go.yourapp.com/.well-known/apple-app-site-association | python3 -m json.tool

Apple's CDN Cache

Apple caches AASA files through their CDN. When you first deploy or update your AASA, it can take up to 24-48 hours for Apple to re-fetch it. Check what Apple currently has cached:

curl "https://app-site-association.cdn-apple.com/a/v1/go.yourapp.com"

To request a re-crawl, use Apple's Search Validation tool and enter your domain.

Step 2: Associated Domains Entitlement

In Xcode

  1. Open ios/YourApp.xcworkspace in Xcode
  2. Select your app target
  3. Go to "Signing & Capabilities"
  4. Click "+ Capability"
  5. Add "Associated Domains"
  6. Add: applinks:go.yourapp.com

This generates (or updates) the YourApp.entitlements file:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.developer.associated-domains</key>
  <array>
    <string>applinks:go.yourapp.com</string>
  </array>
</dict>
</plist>

Multiple Domains

If your links come from multiple domains:

<array>
  <string>applinks:go.yourapp.com</string>
  <string>applinks:links.yourapp.com</string>
</array>

Developer Mode (iOS 14+)

For development and testing, you can use the ?mode=developer suffix:

<string>applinks:go.yourapp.com?mode=developer</string>

This tells iOS to fetch the AASA file directly from your server instead of Apple's CDN, which is useful during development when you're frequently updating the file.

Step 3: AppDelegate Configuration

React Native's RCTLinkingManager bridges the native Universal Link callback to JavaScript.

For Objective-C AppDelegate (AppDelegate.mm)

#import <React/RCTLinkingManager.h>

@implementation AppDelegate

// Handle Universal Links
- (BOOL)application:(UIApplication *)application
   continueUserActivity:(NSUserActivity *)userActivity
     restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler
{
  return [RCTLinkingManager application:application
                   continueUserActivity:userActivity
                     restorationHandler:restorationHandler];
}

// Handle custom URL schemes (optional, but recommended as fallback)
- (BOOL)application:(UIApplication *)application
   openURL:(NSURL *)url
   options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options
{
  return [RCTLinkingManager application:application openURL:url options:options];
}

@end

For Swift AppDelegate (AppDelegate.swift)

import React

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

  func application(
    _ application: UIApplication,
    continue userActivity: NSUserActivity,
    restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
  ) -> Bool {
    return RCTLinkingManager.application(
      application,
      continue: userActivity,
      restorationHandler: restorationHandler
    )
  }

  func application(
    _ app: UIApplication,
    open url: URL,
    options: [UIApplication.OpenURLOptionsKey: Any] = [:]
  ) -> Bool {
    return RCTLinkingManager.application(app, open: url, options: options)
  }
}

SceneDelegate (iOS 13+)

If your app uses UISceneDelegate (the default for newer React Native projects):

// SceneDelegate.m
#import <React/RCTLinkingManager.h>

- (void)scene:(UIScene *)scene
   continueUserActivity:(NSUserActivity *)userActivity {
  [RCTLinkingManager application:[UIApplication sharedApplication]
            continueUserActivity:userActivity
              restorationHandler:^(NSArray<id<UIUserActivityRestoring>> * _Nullable restorableObjects) {
  }];
}

- (void)scene:(UIScene *)scene
   openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts {
  for (UIOpenURLContext *context in URLContexts) {
    [RCTLinkingManager application:[UIApplication sharedApplication]
                           openURL:context.URL
                           options:@{}];
  }
}
import { Linking } from 'react-native';

// Cold start: app was launched by the link
useEffect(() => {
  Linking.getInitialURL().then((url) => {
    if (url) {
      handleUniversalLink(url);
    }
  });
}, []);

// Warm start: app was already running
useEffect(() => {
  const subscription = Linking.addEventListener('url', ({ url }) => {
    handleUniversalLink(url);
  });
  return () => subscription.remove();
}, []);

Universal Links arrive as full HTTPS URLs:

function handleUniversalLink(url) {
  const parsed = new URL(url);

  // Verify it's your domain
  if (parsed.hostname !== 'go.yourapp.com') {
    return;
  }

  const path = parsed.pathname;
  const params = Object.fromEntries(parsed.searchParams);

  // Route to the correct screen
  if (path.startsWith('/product/')) {
    const id = path.split('/')[2];
    navigation.navigate('Product', { id });
  } else if (path.startsWith('/invite/')) {
    const code = path.split('/')[2];
    navigation.navigate('Invite', { code });
  } else {
    // Unknown path: navigate to home or show not-found
    navigation.navigate('Home');
  }
}

Troubleshooting

Check the AASA file:

  • Is it accessible at https://yourdomain/.well-known/apple-app-site-association?
  • Does it return Content-Type: application/json?
  • Does the appID match your Team ID + Bundle ID exactly?
  • Is the path pattern in components (or paths for the older format) correct?

Check the entitlement:

  • Does the Associated Domains entry exactly match your domain? (no https://, no trailing slash)
  • Is the entitlement present in the correct build target (not just the test target)?

Check the device:

  • Universal Links don't work in the iOS Simulator for all scenarios. Test on a physical device.
  • Delete and reinstall the app. iOS fetches the AASA on install and caches it.
  • Check if another app on the device is claiming the same domain.

This happens when:

  • The user typed the URL into Safari's address bar (Universal Links don't trigger from the address bar, by design)
  • The user previously tapped the breadcrumb in Safari to "Open in Safari" for your domain, which disables Universal Links for that domain until they long-press a link and choose "Open in [App]"
  • The AASA is cached with an old or invalid configuration

Different apps handle Universal Links differently:

  • Messages, Notes, Mail: Fully support Universal Links (tapping a link opens the app)
  • Safari: Only works when navigating from another page, not from the address bar
  • Some third-party apps: May use SFSafariViewController or WKWebView, which have different Universal Link behavior

If links work when the app is backgrounded but not when launching the app fresh:

  • Verify that continueUserActivity (or the SceneDelegate equivalent) is called before RCTBridge finishes initialization
  • Verify Linking.getInitialURL() is called early in your app's JavaScript entry point
  • Check that the method is implemented in the correct delegate (AppDelegate vs SceneDelegate)

Testing Checklist

  • AASA file accessible at /.well-known/apple-app-site-association
  • AASA returns Content-Type: application/json
  • appIDs matches Team ID + Bundle ID
  • Associated Domains entitlement includes applinks:yourdomain
  • continueUserActivity implemented in AppDelegate/SceneDelegate
  • Cold start links received via Linking.getInitialURL()
  • Warm start links received via Linking.addEventListener
  • Tested from Notes, Messages, and Mail on physical device
  • App deleted and reinstalled after AASA changes

For Android App Links (the Android equivalent), see the Android section in React Native Deep Linking. For the Tolinku SDK, see the React Native SDK docs. For deep linking features, see Tolinku deep linking.

Get deep linking tips in your inbox

One email per week. No spam.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.