Platform: React Native

React Native deep linking that handles the install gap.

Linking tells you which URL opened the app. It says nothing about the user who tapped a link, did not have the app, installed it, and arrived on your home screen with no idea why they came.

SHORT ANSWER

React Native's Linking API reports the URL that opened the app, and nothing else. It cannot claim a domain, get it verified, or recover a destination through an App Store or Play install. Ferry wraps the native iOS and Android SDKs behind a typed JavaScript API, ships an Expo config plugin that writes the entitlement and intent filter from one linkDomains list, and delivers direct opens and deferred first-install matches to the same callback.

01 TAP A Ferry link from anywhere: email, QR, referral, ad
02 APP OR STORE Native routing opens the app, or the right store
03 ONE CALLBACK Ferry.onLink delivers a typed FerryLink either way
HOW IT WORKS IN REACT NATIVE

Native setup from JavaScript config.

The two platforms need different files in different places, and they need to stay in sync. That is exactly the kind of drift a config plugin exists to prevent.

  1. Linking is delivery, not routing

    Linking.getInitialURL and the url event hand you a string once the app is already open. Everything that happens before that, claiming the domain and passing verification, is native configuration that JavaScript never sees.

  2. Let the config plugin write the native bits

    The Expo config plugin takes a linkDomains array and injects the iOS Associated Domains entitlement and the Android autoVerify intent filter during prebuild, so the two platforms cannot drift apart when you add a hostname.

  3. One typed callback for both cases

    Ferry.onLink returns a subscription and delivers a FerryLink with data, url, clickId, method, confidence and isDeferred. Route from data, and read isDeferred where a fresh install should behave differently from a warm open.

  4. Bare projects work the same way

    Without Expo, add applinks: entries in Xcode and the autoVerify filter in AndroidManifest.xml by hand. No AppDelegate or Activity bridge code is required, because standard React Native linking already forwards URLs into Ferry.handle.

  5. The install gap is server-side

    Ferry recorded the original link open. On the first launch after install the native SDK sends whatever signal that platform has, the server runs the match, and the payload comes back through the same callback. There is no JavaScript matching logic to get wrong.

FERRY REACT NATIVE SDK
useFerryRouting.ts ts
import { useEffect } from 'react';
import { Linking } from 'react-native';
import { Ferry } from 'ferry-react-native';
import type { FerryLink } from 'ferry-react-native';

Ferry.configure('pk_live_xxx');

export function useFerryRouting() {
  useEffect(() => {
    // Fires for an installed-app open and for the deferred first-install match.
    const link = Ferry.onLink((received: FerryLink) => {
      if (received.data.screen === 'product') {
        router.showProduct(received.data.product_id, received.isDeferred);
        return;
      }

      router.showHome();
    });

    Linking.getInitialURL().then((url) => url && Ferry.handle(url));
    const incoming = Linking.addEventListener('url', ({ url }) => Ferry.handle(url));

    return () => {
      link.remove();
      incoming.remove();
    };
  }, []);
}

Call the hook once from your root component. Remove both subscriptions in the cleanup, or a fast refresh in development will leave duplicate handlers routing the same link twice.

WHERE IT BREAKS

Mostly native problems, reported as JavaScript bugs.

The stack trace stops at the bridge, so a missing entitlement looks like a broken callback. These are the ones worth checking first.

What goes wrong What to do about it
getInitialURL returns null after an install. There was no URL to return. The user arrived from the store, not from a link the OS could hand you. That gap is precisely what deferred matching exists to close, and it cannot be solved on the JavaScript side.
The Expo plugin ran but the entitlement is missing. Config plugins only apply during prebuild. If ios/ and android/ are committed to the repository they are not regenerated, so run prebuild with --clean or apply the same changes by hand.
Deep links work in Expo Go and not in the real app. Expo Go has its own bundle identifier and package name, so it can never claim your domain. Universal links and App Links only work in a development build or a release build.
The link arrives before the navigator mounts. A cold start delivers the link early. Ferry buffers a link that resolves before the first onLink subscriber, but your navigation still has to be ready to accept the target. Queue it until the navigator is mounted.
Subscriptions are never removed. Ferry.onLink returns a subscription with a remove method. Call it in the effect cleanup. Otherwise a fast refresh leaves several live handlers and the same link routes more than once.
Links work on iOS and show a chooser on Android. The Android side needs the release signing fingerprint in assetlinks.json. The config plugin writes the intent filter, but it cannot know your Play App Signing certificate.
FAQ

Questions developers ask first.

Does React Native support deep linking without a library?

It supports receiving links. The Linking API reports the URL that opened the app once the native side is configured. It does not claim domains, get them verified, or recover a destination for someone who installed the app after tapping a link.

How do I set up universal links in a React Native app?

Add the Associated Domains entitlement for every hostname on iOS and an autoVerify intent filter on Android, then serve matching apple-app-site-association and assetlinks.json files from that hostname. With Ferry, the Expo config plugin writes the app side from a single linkDomains list and Ferry serves both files.

Does it work with Expo?

Yes. The SDK ships an Expo config plugin that injects the iOS entitlement and the Android intent filter at prebuild. Expo Go itself cannot claim your domains because it has its own bundle identifier, so test links in a development build or a release build.

What is deferred deep linking in React Native?

It is the case where someone taps a link, does not have the app, installs it, and still lands on the right screen. Ferry records the original link open, matches the first launch back to it server-side, and delivers the same payload through Ferry.onLink with isDeferred set to true.

Is the SDK typed?

Yes. It is a TypeScript wrapper around the native SDKs. A FerryLink carries data, url, clickId, method, confidence and isDeferred, and method is deliberately an open string type so a new server value round trips instead of collapsing to a fallback.

Do I need native module code in AppDelegate or MainActivity?

No. Standard React Native linking already forwards the initial URL and later url events, and Ferry.handle takes them from there. Bare projects only need the entitlement and the intent filter added by hand.

TYPED LINKS, NATIVE BEHAVIOUR

Wire it up in an afternoon.

Install the package, list your hostnames once, and test a real install from the store rather than a warm open.