npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dl2app/react-native

v0.2.1

Published

DL2App deep links for React Native and Expo: universal links, app links, deferred deep links and events.

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)

  1. Install the package and the iOS pod. Autolinking registers the native module on both platforms; there is nothing to add to MainApplication or the Podfile.

    npm install @dl2app/react-native
    cd ios && pod install
  2. iOS: 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.com
  3. iOS: 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)
    }
  4. 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.

  5. 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-native

Add 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 (path and params from the URL, empty data), so your app still navigates.
  • A link in your own uriScheme is 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_id made 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 next configure behaves 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

  1. configure loads or creates the install ID (stored in UserDefaults on iOS, SharedPreferences on Android).
  2. 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_url set); later with POST /v1/sdk/resolve.
  3. Otherwise, on first launch only, the SDK asks POST /v1/sdk/open whether 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.
  4. 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.
  5. 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.
  6. 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 -alt host, and a hint for opening the system browser. This is why the -alt host 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.app

Repeat 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 install has 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: true and read the [DL2] lines.
  • "DL2 native module not found": the app was not rebuilt after installing the package (on iOS, run pod install first), 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.