How to Implement Deep Linking and App Links in Flutter and React Native

A deep link is what turns “open the app” into “open the app, on this exact screen, with this exact data.” Tap a link in a text message and land on a specific product page. Tap a password-reset email and land straight on the reset screen with the token already filled in. Share a link to a specific post and whoever opens it, even if they don’t have the app yet, ends up looking at that post.This guide covers everything you need to set deep linking up properly: the different types of links, why they matter, and full walkthroughs for both React Native and Flutter, on iOS and Android.

The three types of deep links

People use “deep link” loosely to mean several related but different things. It helps to separate them clearly before writing any code.

Type What it looks like Where it works Fallback if app isn’t installed
Custom URL scheme myapp://profile/42 Only inside apps or contexts that recognise the custom scheme (not regular browsers) None. The link simply fails to open anywhere.
Universal Link (iOS) / App Link (Android) https://example.com/profile/42 Any browser, any app, any messaging platform Opens the same URL as a normal webpage
Deferred deep link Same as above, resolved after install App stores, ad networks Redirects to the store, remembers the destination, and opens it the first time the app launches post-install

Custom URL schemes are the older, simpler mechanism. You register a made-up prefix like myapp://, and any link starting with it is handed to your app. They are easy to set up but have one serious limitation: if the app isn’t installed, or if the link is opened somewhere that doesn’t understand custom schemes (many in-app browsers, for instance), it just fails silently.

Universal Links (Apple’s term) and App Links (Google’s term) solve that problem. They use a real https:// URL that you already own. If the app is installed, the OS opens it directly in the app instead of a browser. If it isn’t installed, the same link opens as a normal webpage, so you can show a landing page or a “download our app” prompt instead of a dead end. This is why, when you tap a shared link from Facebook Ads and the app isn’t installed, you land on a website rather than getting an error.

Deferred deep links go one step further and are the trickiest to set up yourself: they remember where the user was trying to go even through an app store install. A user clicks a link, doesn’t have the app, gets redirected to install it, and the app opens directly to the intended screen the very first time it launches. Building this reliably from scratch usually means a third-party attribution SDK (Firebase Dynamic Links, historically, though it has been retired; alternatives include Branch, AppsFlyer, Adjust, and others), since it needs server-side matching logic that a plain app can’t do on its own.

Why you usually need both

A common point of confusion: why not just use Universal Links / App Links for everything, since they’re strictly more capable? Two reasons come up in practice.

  • In-app browsers. When someone taps a link inside Facebook, Instagram, or LinkedIn, it often opens in that platform’s own embedded browser rather than Safari or Chrome. Some of these embedded browsers do not honour Universal Links the way the system browser does, so the link opens the plain website instead of the app. A custom URL scheme, triggered from your own app or a controlled context, sidesteps this.
  • Internal navigation you control completely. If you’re linking from one screen of your own app to another, or from a notification you generated yourself, a custom scheme is simpler to reason about and doesn’t need domain verification at all.

The practical rule most teams land on: use Universal Links / App Links as the primary, user-facing mechanism (for marketing links, shared content, emails), and keep a custom URL scheme around as a fallback and for internal use.

React Native: project setup

The examples below assume a bare React Native project (not Expo Go) with two screens to navigate between, using React Navigation. Install React Navigation if you haven’t already:

npm install @react-navigation/native @react-navigation/native-stack
npm install react-native-screens react-native-safe-area-context

React Native on iOS: URL schemes and Universal Links

Setting up a custom URL scheme

Open your project in Xcode, select your target, go to the Info tab, and expand URL Types. Click the add button and fill in two fields:

  • Identifier: your app’s bundle identifier (found on the General tab)
  • URL Schemes: whatever scheme name you want, for example myapp

Reinstall the app (a plain rebuild is enough; you don’t need to delete it first) for the new URL type to take effect. To test it, open Safari and type the scheme directly into the address bar, for example myapp://details. iOS will ask whether you want to open the link in your app.

Setting up Universal Links

Universal Links need one extra piece: proving to Apple that you actually own the domain you’re linking from. You do this by hosting a JSON file on your website.

Hosted at https://yourdomain.com/.well-known/apple-app-site-association
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.yourcompany.yourapp",
        "paths": ["*"]
      }
    ]
  }
}

Two values go into appID:

  • Your Team ID, found on the Signing & Capabilities tab in Xcode
  • Your app’s bundle identifier, from the General tab

This file has to be served exactly right, or verification silently fails. It must be reachable at /.well-known/apple-app-site-association (no .json extension), served over HTTPS with a valid certificate, and returned with a content type of application/json even though the filename has no extension. If your server or CDN adds a redirect on that path, verification will fail. Apple’s official reference for this file is worth bookmarking if something isn’t working: Supporting associated domains.

Back in Xcode, go to Signing & Capabilities, click the + Capability button, and add Associated Domains. Add an entry in the form:

applinks:yourdomain.com

Domain verification is checked when the app is installed or reinstalled, so do a fresh install after adding this. Test it the same way as before, by typing your real HTTPS URL into Safari. If verification succeeded, a banner will offer to open the link in your app; if you tap it, you’re taken there directly. If you only see the plain website with no banner, double-check the hosted JSON file first, since that’s the most common point of failure.

Handling the incoming URL in native code

Both mechanisms need a small addition to your iOS AppDelegate so React Native’s Linking module receives the URL. In Objective-C (AppDelegate.m):

#import <React/RCTLinkingManager.h>

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

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

If your project uses Swift instead (many newer templates default to a Swift AppDelegate.swift), the equivalent looks like this:

import React

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

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

Check your file extension before pasting code. Since React Native 0.71, new projects generate a Swift AppDelegate.swift by default, while older projects and many tutorials still show Objective-C. Copying Objective-C syntax into a Swift file (or the reverse) will not compile. Open your own AppDelegate file first and match its language.

React Native on Android: App Links

Android handles both custom schemes and App Links through the same mechanism: intent filters in AndroidManifest.xml. Open android/app/src/main/AndroidManifest.xml and find your main activity. Two things need to be true for links to route correctly on newer Android versions: the activity should be launchMode="singleTask", and you add one intent filter per link type.

android/app/src/main/AndroidManifest.xml
<activity
  android:name=".MainActivity"
  android:launchMode="singleTask"
  android:exported="true">

  <intent-filter>
    <action android:name="android.intent.action.MAIN" />
    <category android:name="android.intent.category.LAUNCHER" />
  </intent-filter>

  <!-- Custom URL scheme -->
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="myapp" />
  </intent-filter>

  <!-- App Link (Universal Link equivalent) -->
  <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"
      android:host="yourdomain.com" />
  </intent-filter>
</activity>

Notice the difference between the two filters: the custom scheme filter only needs android:scheme, while the App Link filter needs both android:scheme="https" and android:host, plus android:autoVerify="true" so Android checks domain ownership automatically.

Verifying domain ownership for Android

Just as iOS needs the apple-app-site-association file, Android needs its own verification file hosted on your domain.

Hosted at https://yourdomain.com/.well-known/assetlinks.json
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.yourcompany.yourapp",
      "sha256_cert_fingerprints": ["YOUR_SHA256_FINGERPRINT"]
    }
  }
]

You need two values:

  • package_name: your applicationId, found in android/app/build.gradle
  • sha256_cert_fingerprints: the SHA-256 fingerprint of the signing certificate you’ll use to release the app

Get the fingerprint with the keytool command, pointing at whichever keystore matches your build (debug for local testing, your real release keystore for production):

keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android

Copy the SHA-256 value from the output into the JSON file. If your app is already live on the Play Store and uses Play App Signing, Google re-signs your app with its own key, so you’ll also need the fingerprint listed under App signing key certificate in the Play Console, not just your local upload key, or verification will fail for users who installed from the store.

Google provides a Statement List Generator tool to build this file correctly rather than typing it by hand. Search for “Google Digital Asset Links generator” or check the official documentation at Verify Android App Links for the current tool link, since URLs for developer tools change over time.

Wiring links to React Navigation

Once the native side recognises your links, React Navigation needs to know how to map an incoming URL to a screen. This goes through the linking prop on NavigationContainer.

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

const linking = {
  prefixes: ['myapp://', 'https://yourdomain.com'],
  config: {
    screens: {
      Home: 'home',
      Details: 'details/:id',
    },
  },
};

export default function App() {
  return (
    <NavigationContainer linking={linking}>
      {/* your navigator */}
    </NavigationContainer>
  );
}

prefixes lists every scheme and domain that should be treated as a deep link into this app; both your custom scheme and your verified domain go here. config.screens maps a URL path to a screen name in your navigator, and a segment like :id captures a dynamic value that arrives in the screen’s route params.

With that config in place, opening myapp://details/42 or https://yourdomain.com/details/42 both land on the Details screen, with id: '42' available through route.params.id (or, in a class component, this.props.route.params.id).

Handling links manually with the Linking API

You don’t have to use React Navigation’s built-in linking config. For more control, or if you’re not using React Navigation at all, React Native’s Linking module lets you read the URL yourself and decide what to do with it.

import { useEffect } from 'react';
import { Linking, Alert } from 'react-native';

function useDeepLinkHandler(navigation) {
  useEffect(() => {
    // Handles the case where the app was launched by a link (cold start)
    Linking.getInitialURL().then((url) => {
      if (url) {
        handleUrl(url);
      }
    });

    // Handles the case where the app was already open (warm start)
    const subscription = Linking.addEventListener('url', ({ url }) => {
      handleUrl(url);
    });

    return () => subscription.remove();
  }, []);

  function handleUrl(url) {
    const route = url.replace(/.*?:\/\//, '');
    const [screen, id] = route.split('/');

    if (screen === 'details') {
      navigation.navigate('Details', { id });
    }
  }
}

Two details matter here that are easy to miss:

  • You need both listeners. getInitialURL() only fires for the “cold start” case, where the link is what launched the app in the first place. The 'url' event handles the case where the app was already running in the background and a new link comes in. Skipping either one means links only work sometimes, which is a confusing bug to track down.
  • Deep links don’t fire while the JavaScript debugger is attached in older debugging setups. If you test with remote debugging on and nothing happens, that’s often the reason, not a bug in your code.

Testing deep links from the terminal

You don’t need to build a real link and send it to yourself every time. Both platforms let you fire a deep link straight from a terminal, which is far faster for iterating.

Android (via adb, with the app installed on an emulator or device)
npx uri-scheme open myapp://details/42 --android
npx uri-scheme open https://yourdomain.com/details/42 --android
iOS Simulator
npx uri-scheme open myapp://details/42 --ios
xcrun simctl openurl booted "https://yourdomain.com/details/42"

The uri-scheme package (run through npx, no install needed) handles both platforms and both link types, and is the quickest way to check that your intent filters and navigation config actually agree with each other before you involve a real browser or messaging app.

Flutter: setting up App Links (Android)

Flutter’s ecosystem has a well-supported package specifically for this called app_links. Add it to your project:

flutter pub add app_links

The Android setup mirrors what’s needed in React Native, since it’s the same underlying platform mechanism: an intent filter in the manifest, plus a hosted verification file.

android/app/src/main/AndroidManifest.xml
<activity
    android:name=".MainActivity"
    android:launchMode="singleTask"
    android:exported="true">

    <!-- Existing launcher intent-filter stays as is -->

    <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"
            android:host="yourdomain.com" />
    </intent-filter>
</activity>

Host the same kind of Digital Asset Links file as before, at https://yourdomain.com/.well-known/assetlinks.json, using your Flutter app’s applicationId (found in android/app/build.gradle) and the SHA-256 fingerprint of your signing key, retrieved the same way described in the React Native Android section above.

A very common source of confusion: which app is actually opening the link. Android checks the asset links file for every app that has registered an intent filter for that domain. If a link opens in the wrong app, or gives you an app-picker dialog you didn’t expect, check Settings → Apps → your app → Open by default on the test device; you may need to manually enable “Open supported links” the first time, especially before your domain has fully propagated verification, or while testing with a debug build whose fingerprint doesn’t match what’s hosted.

Flutter: setting up Universal Links (iOS)

Host the same apple-app-site-association file described in the React Native iOS section, with your Flutter app’s Team ID and bundle identifier. Then, in Xcode:

  1. Open ios/Runner.xcworkspace (not the .xcodeproj file, which won’t have CocoaPods set up correctly)
  2. Select the Runner target, go to Signing & Capabilities
  3. Click + Capability and add Associated Domains
  4. Add an entry: applinks:yourdomain.com

Reinstall the app on a real device or the simulator so domain verification runs.

Handling links and query parameters in Flutter

With the platform configuration in place, listen for incoming links in your app’s startup logic:

import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';

class DeepLinkListener extends StatefulWidget {
  final Widget child;
  const DeepLinkListener({super.key, required this.child});

  @override
  State<DeepLinkListener> createState() => _DeepLinkListenerState();
}

class _DeepLinkListenerState extends State<DeepLinkListener> {
  final _appLinks = AppLinks();

  @override
  void initState() {
    super.initState();
    _initDeepLinks();
  }

  Future<void> _initDeepLinks() async {
    // Handles the link that launched the app (cold start)
    final initialUri = await _appLinks.getInitialAppLink();
    if (initialUri != null) {
      _handleUri(initialUri);
    }

    // Handles links received while the app is already running
    _appLinks.uriLinkStream.listen(_handleUri);
  }

  void _handleUri(Uri uri) {
    debugPrint('Deep link received: $uri');

    // Example: myapp://details?id=45  or  https://yourdomain.com/details?id=45
    if (uri.path.contains('details') || uri.host == 'details') {
      final id = uri.queryParameters['id'];
      if (id != null) {
        // Navigate to the details screen with this id.
        // Do this only after MaterialApp has finished its first build,
        // for example via a navigatorKey, since navigating before that
        // throws an error.
      }
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

A few points worth calling out, since they trip people up in practice:

  • Query parameters, not manual string splitting. If your link carries data as a query string, like ?id=45, use Dart’s built-in Uri parsing (uri.queryParameters['id']) rather than splitting the string by hand with something like split('/'). Manual splitting breaks the moment a URL has an unexpected extra segment or an encoded character.
  • Navigate after the app has initialized, not before. Calling Navigator.push before MaterialApp has built its navigator throws an error. The reliable pattern is a global GlobalKey<NavigatorState> assigned to MaterialApp(navigatorKey: ...), so you can push routes from anywhere, including this listener, once the app is running.
  • Prefer push over go-style replacement when the user should be able to go back. If your deep link takes someone into the middle of a flow, replacing the whole navigation stack can strand them with no way back to where they started, whereas pushing a new route on top keeps the back button working as expected.

A minimal navigator key setup

final navigatorKey = GlobalKey<NavigatorState>();

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: navigatorKey,
      home: const HomeScreen(),
    );
  }
}

// Later, from anywhere, including the deep link handler above:
navigatorKey.currentState?.push(
  MaterialPageRoute(builder: (_) => DetailsScreen(id: id)),
);

Why not just use packages like uni_links? uni_links was the previous standard package for this in Flutter but is now unmaintained and does not fully support newer Android and iOS versions. app_links is its actively maintained successor and is the one recommended in current Flutter deep-linking guidance.

Common mistakes and gotchas

Symptom Likely cause
Universal Link opens the website instead of the app Domain verification failed: check the hosted JSON file’s path, content type, and that it isn’t behind a redirect
Link works after a fresh install but not after an update Verification only re-runs on install; some OS versions cache the result, so a stale association can persist until reinstall
Custom scheme link does nothing in a social app’s browser Expected behaviour for many in-app browsers; this is exactly why a Universal Link / App Link fallback matters
App opens but doesn’t navigate anywhere Missing the “warm start” event listener (only handling getInitialURL/cold start), or navigating before the navigator has mounted
Wrong app opens the link on Android Another installed app also claims the same domain; check “Open by default” settings, or your asset links file’s fingerprint doesn’t match the installed build
Works on debug build, fails on release/Play Store build Different signing certificate; you need the Play App Signing fingerprint in assetlinks.json, not just your local upload key

Frequently asked questions

What’s the difference between a deep link and a Universal Link?

“Deep link” is the general term for any link that opens an app to a specific screen. A custom-scheme deep link (like myapp://) only works where that scheme is recognised. A Universal Link (iOS) or App Link (Android) uses a normal https:// URL that works everywhere, and falls back to a real webpage if the app isn’t installed.

Do I need a real website to use Universal Links or App Links?

Yes. You need to be able to host a small verification file (apple-app-site-association for iOS, assetlinks.json for Android) at a fixed path on a domain you control, served over HTTPS.

Can I test deep links without publishing my app?

Yes, on both platforms, using a simulator/emulator or a physical device with a debug build installed. Use uri-scheme (React Native) or the corresponding platform tools to fire a link from the terminal, as shown above.

Why isn’t my deep link opening the app at all?

Work through it in order: confirm the intent filter or URL type is registered correctly, confirm you reinstalled the app after adding it, confirm the hosted verification file is reachable and correctly formatted, and confirm you’re testing with the same signing certificate whose fingerprint is in the verification file.

Do I need a third-party SDK for deep linking?

Not for basic deep linking. Both React Native’s Linking API and Flutter’s app_links package, combined with the native configuration above, are enough for opening the app to a specific screen. A third-party SDK becomes worth considering mainly for deferred deep linking (surviving an app-store install) or for attribution and analytics on where your links are being clicked.

How do I pass data through a deep link, like a user ID or a referral code?

Add it as a query parameter, for example https://yourdomain.com/post?id=123, and read it on the receiving end with your platform’s URL parsing (route.params via React Navigation’s config, or Uri.queryParameters in Flutter) rather than manually slicing the string.

Conclusion

Deep linking has more moving parts than it first appears: a custom scheme for guaranteed internal use, a verified domain for links that need to work everywhere, and platform-specific configuration on both iOS and Android that has to line up exactly with what you host on your server. Get the verification files right first, since almost every “it doesn’t work” case traces back to a misconfigured or unreachable hosted file, then build the in-app routing on top once you can confirm the OS is actually handing your app the link.

Start with the custom scheme on both platforms, since it’s the fastest to verify end to end, then add the verified domain once that’s working, and test every change with the terminal commands above before involving a real device’s browser or messaging apps.

Leave a Reply

Your email address will not be published. Required fields are marked *