Deep Blue Links
Documentation menu

React Native

Open your React Native app from its links, resolve each link with the API, and find the link that led to an install.

This guide uses React Native's Linking for incoming links and react-native-device-info for the Android install referrer; Expo apps can use expo-application instead. Replace demo with your app's name and YOUR_API_KEY with its key: the sandbox key in debug builds and the live key in release builds.

1. Set up iOS and Android

iOS: in Xcode, select your app's target, open Signing & Capabilities, add Associated Domains and enter applinks:demo.dev.thedbl.ink, plus one entry per custom domain. If your app has a URL scheme, add it under Info › URL Types too.

From React Native 0.88 the template's SceneDelegate.swift already passes universal links and URL scheme links to React Native, through RCTLinkingManager.scene(_:continue:) and RCTLinkingManager.scene(_:openURLContexts:), so iOS needs no code. An app created from an older template, with no SceneDelegate.swift, passes universal links on in AppDelegate.swift, which needs import React:

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

Xcode 27 builds only for iOS 15 and later, and some packages, such as AsyncStorage and DeviceInfo, still ship resource bundles for earlier versions, which stop the build. Raise every pod to React Native's minimum at the end of the Podfile's post_install, after react_native_post_install:

ios/Podfile
installer.pods_project.targets.each do |target|
  target.build_configurations.each do |build_configuration|
    if build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < min_ios_version_supported.to_f
      build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported
    end
  end
end

Android: in android/app/src/main/AndroidManifest.xml, add a verified intent filter to .MainActivity, plus a filter for your URL scheme if your app has one:

android/app/src/main/AndroidManifest.xml
<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="demo.dev.thedbl.ink" />
</intent-filter>

<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>

Expo: put them in app.json instead, and rebuild the native projects:

app.json
{
  "expo": {
    "scheme": "myapp",
    "ios": { "associatedDomains": ["applinks:demo.dev.thedbl.ink"] },
    "android": {
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [{ "scheme": "https", "host": "demo.dev.thedbl.ink" }],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

Your app's settings must have the iOS team and bundle IDs and the Android package and signing fingerprints the builds use.

deepBlueLinks.ts
import { Dimensions, PixelRatio, Platform } from 'react-native'
import DeviceInfo from 'react-native-device-info'

const API_KEY = 'YOUR_API_KEY'

export type ResolvedLink = { id: number; longUrl: string; shortUrl: string; isSandbox?: boolean }

async function post(path: string, body: unknown): Promise<ResolvedLink | null> {
  const response = await fetch(`https://dev.thedbl.ink${path}`, {
    method: 'POST',
    headers: { 'Api-Key': API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  if (response.status === 404) return null
  if (!response.ok) throw new Error(`Deep Blue Links answered ${response.status}`)
  return response.json()
}

export const resolveLink = (url: string) => post('/api/links/resolve', { url })

/** The path and query of an https URL, such as /products/1234?ref=mail. */
export const pathOf = (url: string) => url.replace(/^https?:\/\/[^/?#]+/i, '') || '/'

Handle the URL that launched the app and every URL that arrives while it runs:

App.tsx
import { useEffect } from 'react'
import { Linking } from 'react-native'
import { pathOf, resolveLink, type ResolvedLink } from './deepBlueLinks'

async function openUrl(url: string) {
  if (url.startsWith('myapp://')) {
    router.open('/' + url.slice('myapp://'.length))
    return
  }
  let link: ResolvedLink | null = null
  try {
    link = await resolveLink(url)
  } catch (error) {
    console.warn(`Couldn't resolve ${url}`, error)
  }
  router.open(pathOf(link?.longUrl ?? url))
}

export default function App() {
  useEffect(() => {
    Linking.getInitialURL().then((url) => { if (url) openUrl(url) })
    const subscription = Linking.addEventListener('url', ({ url }) => openUrl(url))
    return () => subscription.remove()
  }, [])
  // ...
}

router stands for your app's navigation, such as React Navigation's navigate. A 404 from resolve means the URL isn't one of your short links, such as a link's full URL, so it's routed as it is, and so is a URL whose resolve fails, such as with no network. The myapp:// branch handles your URL scheme: everything after myapp:// is the link's path. When link.isSandbox is true, load the destination's content from your sandbox backend.

On Android, read the short link from the Play install referrer. Elsewhere, call check-deferred with the timezone and the screen in physical pixels:

deepBlueLinks.ts
function linkFromReferrer(referrer: string): string | null {
  for (const pair of referrer.split('&')) {
    const at = pair.indexOf('=')
    if (at > 0 && pair.slice(0, at) === 'link') return decodeURIComponent(pair.slice(at + 1))
  }
  return null
}

export async function findInstallLink(): Promise<ResolvedLink | null> {
  if (Platform.OS === 'android') {
    const referrer = await DeviceInfo.getInstallReferrer()
    if (referrer !== 'unknown') {
      const link = linkFromReferrer(referrer)
      return link ? resolveLink(link) : null
    }
    const { timeZone } = Intl.DateTimeFormat().resolvedOptions()
    return post('/api/links/check-deferred', { timezone: timeZone })
  }
  const { width, height } = Dimensions.get('screen')
  const ratio = PixelRatio.get()
  const { timeZone, locale } = Intl.DateTimeFormat().resolvedOptions()
  return post('/api/links/check-deferred', {
    timezone: timeZone,
    device: { screenWidth: Math.round(width * ratio), screenHeight: Math.round(height * ratio), language: locale },
  })
}

getInstallReferrer answers unknown until DeviceInfo has read the referrer from Google Play, which it starts doing when the app launches, and it keeps that first answer for the rest of the session. On a first launch the answer can come too early, and an install from another store never has one, so the code then asks check-deferred with the timezone alone. In an Expo app, read the referrer with await Application.getInstallReferrerAsync() from expo-application, which waits for Google Play's answer.

Call findInstallLink() once, on the first launch (store a flag, for example with AsyncStorage), and route with pathOf(link.longUrl) when it returns a link. A null result is normal: most launches don't follow a link.

Test it

  • iOS universal links work on a device: paste a short link into Notes and tap it.
  • On Android, check verification with adb shell pm get-app-links com.example.demo, then open a link with adb shell am start -a android.intent.action.VIEW -d "https://demo.dev.thedbl.ink/Xy3kP9aQ".
  • The install referrer is only present for installs from Google Play, such as an internal testing track opened from a link's preview page.