@dl2app/react-native
v0.2.1
Published
DL2App deep links for React Native and Expo: universal links, app links, deferred deep links and events.
Maintainers
Readme
@dl2app/react-native
DL2 deep links for React Native, with or without Expo.
- Opens the right screen when a DL2 link opens your app (iOS Universal Links, Android App Links, optional custom scheme).
- Deferred deep links: a user who installs from a link lands on that link's screen on first launch.
- Event tracking with an offline queue.
- Never crashes your app: every method catches its own errors and reports them through
onError. - No IDFA, no advertising ID, no tracking. About 13 KB of JavaScript, minified.
Requirements
- React Native 0.74 or newer, old or new architecture.
- Works in a plain React Native app and in Expo (SDK 51 or newer). The native code is a plain React Native module: it does not need Expo or Expo Modules.
- iOS: the minimum follows your React Native version (iOS 15.1 on current releases). Builds with Xcode 27.
- Version 0.2.1 was compiled for Android and iOS with Xcode 27 in a React Native 0.85 app and a fresh Expo SDK 57 app, and run in the React Native app on an Android phone and an iOS 18 simulator.
- Expo: a development build. Expo Go does not work, because the SDK has native code and your app needs its own associated domains.
Install
React Native (no Expo)
Install the package and the iOS pod. Autolinking registers the native module on both platforms; there is nothing to add to
MainApplicationor thePodfile.npm install @dl2app/react-native cd ios && pod installiOS: in Xcode, open your target, Signing & Capabilities, add Associated Domains, and add one line per host:
applinks:myapp.dl2.app applinks:myapp-alt.dl2.app applinks:go.myapp.comiOS: pass incoming links to React Native. The React Native template does not do this for you. In
AppDelegate.swift:import React // Inside your AppDelegate class. Write "override func" if it subclasses RCTAppDelegate. func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { return RCTLinkingManager.application(app, open: url, options: options) } func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { return RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) }Android: in
android/app/src/main/AndroidManifest.xml, inside the main<activity>:<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="myapp.dl2.app" /> <data android:scheme="https" android:host="myapp-alt.dl2.app" /> <data android:scheme="https" android:host="go.myapp.com" /> </intent-filter>The main activity must keep
android:launchMode="singleTask"(the template's default), so a link opened while the app runs reaches the running app.Rebuild the app (
npx react-native run-ios/run-android). A Metro reload is not enough after adding native code.
Expo
npx expo install @dl2app/react-nativeAdd the config plugin to app.json:
{
"expo": {
"plugins": [
["@dl2app/react-native", { "subdomain": "myapp", "domains": ["go.myapp.com"] }]
]
}
}Then rebuild the native app: npx expo prebuild --clean and npx expo run:ios / npx expo run:android, or a new EAS build.
Plugin options:
| Option | Default | What it does |
| --- | --- | --- |
| subdomain | none | Your DL2 project subdomain. Adds myapp.dl2.app and myapp-alt.dl2.app. |
| domains | [] | Custom domains. Without subdomain, list every host here, including the -alt one. |
| iosDeveloperMode | false | Adds ?mode=developer to the iOS entries the plugin adds (entries you already list are left as they are). Use it for development builds only. |
| androidAutoVerify | true | Adds android:autoVerify="true" to the intent filter. |
| uriScheme | none | Also registers a custom scheme such as myapp. |
| rootDomain | dl2.app | Only for self-hosted DL2. |
You need subdomain or at least one entry in domains. The plugin stops the build with a message that says what to change when an option is wrong, for example a host written with https://.
The plugin adds to ios.associatedDomains, android.intentFilters and scheme. It keeps the entries you already have and is safe to run any number of times. An existing https intent filter only counts as covering a host when it is not limited to a path, port or type. The Play Install Referrer library comes with the SDK's own Gradle file, so there is nothing to add for it.
Always include the -alt host. Pages on your main link host use it for their "Open in app" button: iOS only opens an app from a tap that crosses to a different domain.
The dashboard (Project, Setup) shows these snippets filled in with your hosts.
Use
import DL2 from "@dl2app/react-native";
// Once, as early as you can (top of App.tsx or app/_layout.tsx, outside any component).
DL2.configure({
publishableKey: "pk_live_xxx",
linkDomains: ["myapp.dl2.app", "myapp-alt.dl2.app", "go.myapp.com"],
});
// One listener for all three cases: cold start, a link while the app runs,
// and the deferred link on first launch after install.
const unsubscribe = DL2.onLink((event) => {
navigation.navigate(event.path, event.data);
});With Expo Router, also add app/+native-intent.ts so the router does not try to route the short link itself. See examples/rn-expo-example.
API
DL2.configure(options)
| Option | Default | |
| --- | --- | --- |
| publishableKey | required | pk_live_... from the dashboard. Safe to ship in the app. |
| linkDomains | required | Every host your links use. URLs on other hosts are ignored. |
| uriScheme | none | Custom scheme to handle as well, for example "myapp". |
| deferred.enabled | true | Check for a deferred link on first launch. |
| deferred.timeoutMs | 4000 | Longest the first-launch check may take. |
| deferred.useClipboard | false | Allow clipboard matching on iOS. See below. |
| apiBaseUrl | https://api.dl2.app | |
| debug | false | Log what the SDK does to the console. |
| onError | none | Called with a DL2Error whenever something goes wrong. |
Calling configure again does nothing, apart from updating debug and onError.
DL2.onLink(listener) returns unsubscribe
type DL2LinkEvent = {
url: string | null; // the URL that opened the app, or the matched short link
path: string; // "/invite/42"
params: Record<string, string>; // query parameters
data: Record<string, unknown>; // the link's deep link data
source: "universal_link" | "app_link" | "uri_scheme" | "deferred";
isFirstOpen: boolean;
matchMethod?: "install_referrer" | "fingerprint" | "clipboard"; // deferred only
confidence?: number; // deferred only, 0 to 1
linkId?: string;
utm?: Record<string, string>;
};- Links that arrive before any listener is registered are kept (up to 20) and delivered to the first listener.
- The same URL within 2 seconds is delivered once.
- If the DL2 API cannot be reached within 3 seconds, the event is built from the URL itself (
pathandparamsfrom the URL, emptydata), so your app still navigates. - A link in your own
uriSchemeis always built from the URL, without a request: DL2 only stores https links. - Android: a link is not delivered again when the app is reopened from Recents after its process was stopped.
DL2.getInitialLink() returns Promise<DL2LinkEvent | null>
Resolves when the cold-start link and the deferred check are both finished, or have timed out. Use it to hold your splash screen until you know where to go. The same event also reaches onLink.
DL2.resolve(url) returns Promise<DL2LinkEvent | null>
Resolves any DL2 link, for example one scanned from a QR code inside your app. Does not call onLink. Returns null on failure.
DL2.track(name, { value?, currency?, props? })
Records an event, for example DL2.track("purchase", { value: 19.99, currency: "USD", props: { sku: "A1" } }). Events are tied to the link that brought the user in.
- Returns at once. Events are sent in order in the background.
- Offline, events wait in a queue that survives app restarts. The queue holds 100 events; beyond that the oldest are dropped.
- Failed deliveries are retried after 2, 4, 8 seconds and so on, up to 5 minutes apart.
DL2.flush()tries immediately. - Delivery is at least once: after a timeout an event can be sent twice. Each event carries an
event_idmade when it is queued, the same on every retry, so the server can count it once. Events are stamped with the time the server receives them. - An event the server rejects (a 4xx answer other than 429) is dropped and reported through
onError.
DL2.createLink({ path, data?, og?, utm? }) returns Promise<{ url } | null>
Creates a link from the app, for example a share or invite link. Works only if the project allows link creation with a publishable key.
Clipboard matching on iOS (opt-in)
Off by default. On iOS, when a user installs from a link, matching uses network and device signals and can miss, for example behind a VPN or iCloud Private Relay. An exact alternative is a token on the clipboard.
The SDK never reads the clipboard by itself. There is no clipboard-reading function in the native module. Instead you show Apple's own paste button, and iOS hands the text over only when the user taps it, without the "pasted from" banner:
import DL2, { DL2PasteButton } from "@dl2app/react-native";
DL2.configure({ ..., deferred: { useClipboard: true } });
// For example on your first onboarding screen:
<DL2PasteButton style={{ width: 160, height: 44 }} onPaste={(text) => DL2.matchClipboard(text)} />DL2PasteButton needs iOS 16 or newer and renders nothing on older iOS and on Android. DL2.matchClipboard(text) does nothing unless useClipboard is on, it is the first launch, the text contains a DL2 token and no deferred link was delivered yet. On a match the event goes to onLink with matchMethod: "clipboard".
Other methods
DL2.getInstallId(): the random ID the SDK created for this install.DL2.getDebugInfo(): install ID, first-launch state, queued events, last API responses and last error.DL2.reset(): for development. Forgets the install so the nextconfigurebehaves like a fresh install.
Errors
onError receives { code, message, cause?, status? }. No method throws or rejects.
| code | Meaning | What to do |
| --- | --- | --- |
| not_configured | A method ran before configure, or configure got bad options. | Call configure first with a key and linkDomains. |
| network | The request did not reach DL2 (retried once). | Usually the device is offline. Links still open using the URL itself. |
| timeout | No answer in time. | Same as network. |
| invalid_key | The key was rejected. | Copy the publishable key again from the dashboard. |
| app_mismatch | The bundle ID or package name is not an app of this project. | Add the app in the dashboard, or fix the ID. |
| link_not_found | The link does not exist. | The event still carries the URL's path. |
| rate_limited | Too many requests. | Events are retried later by themselves. |
| unknown | Anything else; see message and cause. | |
How first launch works
configureloads or creates the install ID (stored inUserDefaultson iOS,SharedPreferenceson Android).- If the app was opened by a DL2 link, that link is used. On a first launch it is reported with
POST /v1/sdk/open(launch_urlset); later withPOST /v1/sdk/resolve. - Otherwise, on first launch only, the SDK asks
POST /v1/sdk/openwhether this install came from a link. Android sends the Play install referrer, which is exact. iOS is matched on network and device signals; DL2 returns nothing rather than risk giving a user someone else's link. - If the device is offline on first launch, or the API answers 429 or 5xx, the check is tried again on the next launch, three launches at most.
- When the app was installed more than 24 hours ago (for example when the SDK arrives in an app update), the first-launch call carries
skip_fingerprint: true: the install is recorded and exact matches (Play install referrer, clipboard) still work, but it is not matched on network and device signals. - Android: when a backup or device transfer restores the SDK's storage into a new install, the restored install ID, first-launch flag and queued events are discarded and the launch counts as a first launch.
Troubleshooting
The link opens the browser or the store even though the app is installed
- You typed or pasted the link into the browser's address bar. Both platforms open apps only from a tap on a link. Put the link in Notes or Messages and tap it.
- You tapped it inside an app's built-in browser (Instagram, TikTok, Facebook, LinkedIn, Snapchat). These often do not hand links to apps. DL2 shows a page with an "Open the app" button that uses your
-althost, and a hint for opening the system browser. This is why the-althost must be in your app's configuration. - iOS, after a long-press and "Open in Safari": iOS remembers that choice for the domain. Long-press the link again and choose "Open in your app".
Check the files Apple and Google read
# iOS: must return JSON containing "TEAMID.your.bundle.id", with no redirect
curl -i https://myapp.dl2.app/.well-known/apple-app-site-association
# What Apple's CDN has cached. Devices read this copy, and it can lag by hours.
curl https://app-site-association.cdn-apple.com/a/v1/myapp.dl2.app
# Android: must list your package and the SHA-256 of the certificate that signed the installed build
curl -i https://myapp.dl2.app/.well-known/assetlinks.json
curl "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://myapp.dl2.app&relation=delegate_permission/common.handle_all_urls"
adb shell pm get-app-links com.example.app # every host should say "verified"
adb shell pm verify-app-links --re-verify com.example.appRepeat for the -alt host and each custom domain. With Play App Signing, the fingerprint comes from Play Console, Test and release, App integrity, App signing key certificate, not from your local keystore. Add the debug and upload fingerprints too if you test those builds.
On iOS development builds, set iosDeveloperMode: true and turn on Settings, Developer, Associated Domains Development on the device, so iOS fetches the file from your host instead of Apple's CDN. Do not ship that setting to the App Store.
Simulator and emulator limits
- iOS Simulator: Universal Links work (
xcrun simctl openurl booted https://myapp.dl2.app/abc). There is no App Store, so the install step of a deferred link cannot be tested. To test the first-launch call, tap a link in Simulator Safari, then run a fresh install of your build from Xcode within the matching window. - Android emulator: use a Google Play system image. Without Play Store there is no install referrer, and the SDK reports none. Test App Links with
adb shell am start -a android.intent.action.VIEW -d "https://myapp.dl2.app/abc". - The Play install referrer is only filled in for installs that come from the Play Store (internal testing tracks count). A build installed with
adb installhas none. - Deferred links are delivered once per install. Use
DL2.reset()or reinstall to test again.
Nothing happens and there is no error
- Turn on
debug: trueand read the[DL2]lines. - "DL2 native module not found": the app was not rebuilt after installing the package (on iOS, run
pod installfirst), or it is running in Expo Go. - The host of the URL is not in
linkDomains. The SDK ignores URLs on other hosts.
Deferred links do not match on iOS
Matching needs the click and the first launch to come from the same network within the matching window. VPNs, iCloud Private Relay and a switch between Wi-Fi and mobile data all lower the match rate. That is expected: DL2 prefers no match over a wrong one.
Privacy
What the SDK sends to DL2:
| Data | When | Why |
| --- | --- | --- |
| Install ID (random, made by the SDK, reset on reinstall) | Every request | Deliver a deferred link once; tie events to a link |
| OS version, app version, screen size, language, time zone | First launch | Match the install to the click |
| IP address (seen by the server; stored only truncated or as a rotating hash, kept 24 hours at most for matching) | Every request | Match the install to the click |
| Play install referrer (Android) | First launch | Exact match to the click |
| Clipboard text | Only if you enable it and the user taps the paste button | Exact match to the click |
| Events you send with track | When you call it | Your analytics |
The SDK does not read the IDFA or the Android advertising ID, does not use any other device identifier, does not track users across apps or sites, and DL2 does not sell or share this data. You do not need the App Tracking Transparency prompt for DL2.
The package includes ios/PrivacyInfo.xcprivacy (no tracking, no tracking domains, UserDefaults reason CA92.1, file timestamp reason C617.1: the creation date of the app's own Library directory, read as the install time). Xcode merges it into your app's privacy report.
App Store: App Privacy answers
Suggested answers for the DL2 part of your app. Your own data use may add more.
| Question | Answer |
| --- | --- |
| Do you or your third-party partners collect data from this app? | Yes |
| Identifiers, Device ID | Collected. Purposes: App Functionality, Analytics. Linked to the user: No. Used for tracking: No. |
| Usage Data, Product Interaction | Collected (link opens, and events if you call track). Purposes: App Functionality, Analytics. Linked: No. Tracking: No. |
| Diagnostics, Other Diagnostic Data | Collected (OS version, screen size, language, time zone). Purpose: App Functionality. Linked: No. Tracking: No. |
| Location, Coarse Location | Not collected by the SDK. DL2 derives country and city from the IP address for click statistics and does not store the address. |
| Purchases | Declare it only if you send purchase values with track. |
| Is data used to track users? | No |
If your events include a user ID or anything that identifies a person in props, answer "Linked to the user: Yes" for Product Interaction.
Google Play: Data safety answers
| Question | Answer |
| --- | --- |
| Does your app collect or share any of the required user data types? | Yes |
| Is all of the user data collected by your app encrypted in transit? | Yes |
| Device or other IDs | Collected, not shared. Purposes: App functionality, Analytics. Processed ephemerally: No. Required. |
| App activity, App interactions | Collected, not shared. Purposes: App functionality, Analytics. |
| App info and performance, Diagnostics | Collected, not shared. Purpose: App functionality. |
| Location | Not collected. |
| Financial info, Purchase history | Only if you send purchase values with track. |
| Can users request that data is deleted? | Answer according to your own policy. Reinstalling the app creates a new install ID. |
DL2 acts as your service provider (data processor), so sending data to DL2 is "collected", not "shared", under Google's definitions.
Development
npm run typecheck # tsc with this package's tsconfig
npm run build # compiles src/ and plugin/src/ into build/ (app.plugin.js loads build/plugin/src/withDL2.js)Tests live in the repository root: npx vitest run tests/sdk-rn.test.ts.
