Skip to content

About

Strait SDK for React Native and Expo: deep links, deferred deep links (first open after install) and link-open analytics. Expo config plugin included.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Strait

Strait SDK for React Native

Straight to the screen. On the record.
A tap opens the exact screen, and every link open and install is recorded in your Strait dashboard.

Latest version SDK: Beta iPhone links: live · install matching: opt-in CI Licence: MIT

Docs · Platform status · Changelog · Talk to the Strait team

@straitlink/react-native

Deferred deep linking for React Native: the user taps your link, installs the app, and lands on the right screen. On iPhone your workspace chooses the deferred-link method in the dashboard (device matching, paste handoff, both, or neither; device matching is off by default for new workspaces); the SDK reads that choice from Strait at runtime (contract B19).

Part of Strait. The match signature stays in lockstep with the server and the other app SDKs via shared golden vectors (run in CI here).

Set it up with one command

The 5-minute path. In your app's folder (Expo or bare React Native; Node 18.17 or newer, nothing to install first), with the publishable key from Dashboard → Get started:

npx @straitlink/cli init --key st_pub_live_… --host <your-handle>.strait.link --dry-run   # show what it will write
npx @straitlink/cli init --key st_pub_live_… --host <your-handle>.strait.link             # write it

It finds your app from its files, writes links.ts or links.js (plus app/+native-intent.tsx with Expo Router, or src/links.ts and src/app/+native-intent.tsx when your routes live in src/app) with your key, link address and an onLink handler, and prints what's left for you: the install command, the Android intent filters (or the Expo app.json block), the iPhone Associated Domains entry and what to fill in under Dashboard → Apps & stores. It never edits your existing files. Then check that your link domain lists your app:

npx @straitlink/cli doctor --host <your-handle>.strait.link --android <your.package> --ios <your.bundle.id>

Rather do it by hand? Follow the quick start below.

Quick start (60 seconds)

1. Install the SDK and the three packages the setup in Use imports. Expo:

npx expo install @straitlink/react-native @react-native-async-storage/async-storage react-native-play-install-referrer expo-application

Bare React Native (then swap expo-application for react-native-device-info, see Use):

npm install @straitlink/react-native @react-native-async-storage/async-storage react-native-play-install-referrer react-native-device-info

2. Check your key and a link. In Dashboard → Links, make a link with the path summer and the destination https://shop.example.com/product/42. Then ask Strait about it the way this SDK will:

curl -s https://<your-handle>.strait.link/v1/resolve -H 'Content-Type: application/json' \
  -d '{"publishableKey":"st_pub_live_…","url":"https://<your-handle>.strait.link/summer","platform":"android"}'

You should see: {"matched":true,"longUrl":"https://shop.example.com/product/42",…}. The key comes from Dashboard → Get started and is safe in apps. "reason":"not_found" means the key is from another workspace or the path is wrong. The check counts as one tap in Dashboard → Analytics.

3. Wire it. Paste the createStrait block from Use into your app's entry file, with your key and link address, and log every link: strait.onLink((e) => console.log('Strait link:', e.matched, e.path, e.params));. Rebuild, because the SDK adds native code.

You should see: on the first start, Strait link: false undefined undefined: the SDK checked once for a link and found none. To make a tap open the app, follow the quickstart.

Platform features

Feature Status Notes
Android: direct links and deferred links ● Live Exact match through the Play Install Referrer native module.
iPhone links (Universal Links) ● Live The links themselves (Apple's Universal Links plus our association file) were proven on a real iPhone with the Swift reference app on 21 Oct 2026. This SDK's iOS glue is covered by unit tests and hasn't run on a real iPhone.
iPhone: install matching (deferred links) Opt-in Off until you choose it. Proven on iPhone simulators; the real-iPhone run waits on our App Store listing. Your choice in Dashboard → Settings → iPhone installs: device matching, paste handoff, both or neither, read from Strait at runtime. Device matching is off by default. iPhone matches are labelled Estimated until measured.
Store sheet: the store inside your app New Keeps the deep link through the install. On iPhone it needs your app's App Store ID.
Expo config plugin New Writes Associated Domains, the App Links filter (autoVerify) and the scheme from app.json.
New Architecture (bridgeless, Fabric) ● Live Built and run on Android and iOS in the example app.
Web (react-native-web, Expo web) New Web opens are counted only for landings from a Strait tap (the URL carries strait_click); needs an engine with web tap ids. Such a landing sends one open joined to the tap (openId web-<tap id>, so a reload counts once) and attributes later events. Any other page URL still reaches your link callback for routing but is not reported (a page view, not an open). A website has no install, so no deferred check runs and no visit counts as an install. The example app is built for the web in CI.
Published on npm ● Live npm install @straitlink/react-native

● Live · ◐ Beta · ○ Planned · – Not yet · Opt-in: off until you switch it on · New: released, still young. The same words as the platform status page.

Install

npm install @straitlink/react-native

The setup in Use also needs these (it imports them; without them the app fails with "Unable to resolve module"):

# Expo
npx expo install @react-native-async-storage/async-storage react-native-play-install-referrer expo-application
# Bare React Native
npm install @react-native-async-storage/async-storage react-native-play-install-referrer react-native-device-info

AsyncStorage keeps the once-per-install flag and unsent reports; the Play Install Referrer gives Android its exact deferred match; expo-application / react-native-device-info give the install time and app version.

Expo: the config plugin

Add the plugin with your link domain (Dashboard → Get started) and, if you use one, your custom scheme:

{
  "expo": {
    "plugins": [["@straitlink/react-native", { "domains": ["<your-handle>.strait.link"], "scheme": "yourapp" }]]
  }
}

npx expo prebuild (or npx expo run:android / run:ios, or an EAS build) then writes the iOS Associated Domains entitlement (applinks:<your-handle>.strait.link), an Android App Links intent filter with android:autoVerify="true" and the URL scheme on both platforms. It fills Expo's own ios.associatedDomains, android.intentFilters and scheme fields, keeps what you already have there and adds nothing twice. Several domains (a custom domain too) go in the same list. Expo Go can't open your links: use a development build.

You should see: after npx expo prebuild, android/app/src/main/AndroidManifest.xml has <intent-filter android:autoVerify="true"> with android:host="<your-handle>.strait.link", and ios/<App>/<App>.entitlements lists applinks:<your-handle>.strait.link.

New Architecture

Works on React Native's New Architecture (TurboModules, Fabric, bridgeless) and on the old one. React Native 0.82 and later only have the New Architecture; the example app (React Native 0.86, Expo 57) is built for Android and iOS on every change and opens Strait links on the Android emulator and the iOS Simulator.

Use

Create one client when the app starts. start() runs the deferred check once per install (the first launch after an install) and then handles every link that opens the app, in every app state, with open reporting included:

import AsyncStorage from '@react-native-async-storage/async-storage';
import { PlayInstallReferrer } from 'react-native-play-install-referrer';
import * as Application from 'expo-application';
import { createStrait, fromPlayInstallReferrer } from '@straitlink/react-native';

const strait = createStrait({
  publishableKey: 'st_pub_live_…',                         // Dashboard → Get started (safe in apps; never the secret key)
  endpoint: 'https://<your-handle>.strait.link',           // your workspace's link domain
  storage: AsyncStorage,                                   // required for reliable analytics
  installReferrer: fromPlayInstallReferrer(PlayInstallReferrer),
  // Reinstalls from a link (see "Reinstalls and backups"). Expo: expo-application;
  // bare React Native: react-native-device-info's getFirstInstallTime().
  installTime: async () => (await Application.getInstallationTimeAsync()).getTime(),
  // Which build opened a link, for support. Bare React Native: react-native-device-info's getVersion().
  appVersion: Application.nativeApplicationVersion ?? undefined,
});
strait.onLinkStart(() => showLoading());
strait.onLink((e) => e.matched && e.path && navigate(e.path, e.params));
strait.start();

Bare React Native (no Expo): import react-native-device-info instead of expo-application and use installTime: () => DeviceInfo.getFirstInstallTime() and appVersion: DeviceInfo.getVersion() (import DeviceInfo from 'react-native-device-info';).

Reinstalls and backups (installTime, contract B6a)

Android backs up app storage (Auto Backup, on by default) and puts it back when the app is installed again; restored iPhones bring it back too. Without installTime the restored "deferred check done" flag makes a reinstall from a link skip its deferred link and go uncounted. With it, the SDK stores the install time next to the flag and runs the check again when the install time changed. An app update keeps its install time, so it never counts as a new install, and an app updated from an older SDK adopts the time without a new check. Optional extra on Android: exclude AsyncStorage from backup in your dataExtractionRules / fullBackupContent.

For a settings or debug screen, strait.checkDeferred() asks again without touching the once-per-install flag, and the engine adds no install for it.

Legacy: resolveDeferredLink({ publishableKey, endpoint }) is the older one-call API. It has no once-per-install guard and no open reporting, so every call counts as a first launch (an install row and an install.matched webhook). Use createStrait in new code.

Old Firebase page.link links (contract B22)

Moving off Firebase Dynamic Links? People who already have your app can keep opening it from old <x>.page.link links, as long as Google keeps serving page.link's app-link files (it still does today; nobody but Google controls page.link):

  1. Keep applinks:<x>.page.link (iOS) and the page.link intent filter (Android) in your next app build.
  2. Add the host: createStrait({ …, linkHosts: ['<x>.page.link'] }).
  3. Import your old links in the dashboard (Import → Firebase); each keeps its old host and code.

A page.link short link (https://<x>.page.link/aBcD) is then looked up like any Strait short link; a long link (https://<x>.page.link/?link=https://…) opens its link= destination on the device, with no network call. People without your app still land on whatever Google serves. Tested in unit tests only, not yet on a real iPhone.

What Strait records automatically (no extra code)

Every time a link opens the app, the SDK reports it once (contract B14):

How the app opened Reported via Joined to
Verified link tapped in WhatsApp, Gmail, Messages… /v1/resolve (the lookup is the report) the link; also counted as a tap
Browser handed off to the app (yourapp://…) /v1/open the exact tap (strait_click, removed from the event's url, path and params; rawUrl is the URL as the OS gave it)
First open after a Play install /v1/referrer the exact tap that sent the user to the store
First open after an App Store install /v1/match the matched tap
Your own https links /v1/open the URL's host + path (and utm_source, for the channel)

Reports that can't be sent (offline, server busy) are saved in storage and retried on the next start and whenever the app returns to the foreground, for up to 7 days (max 100). When the server is busy (429 or 5xx) the SDK waits before sending the saved ones again: 15–30 s, doubling each time in a row up to 1 hour, and at least as long as the server's Retry-After. Reports and the saved queue never contain the query string or fragment of the opened URL, only host + path and utm_source (contract B18). The engine de-duplicates by open id, so nothing is counted twice. Navigation never waits for a report. The first launch of an install is marked as such, so dashboards can tell new users (installed and opened) from existing users (already had the app). The deferred check is only marked done once the server answered, so an offline first launch is retried on the next launch.

Each of these reports also carries your app's version (appVersion, when you pass it) and this SDK's (sdkVersion), so support can see which build opened a link; /v1/lookup and /v1/event carry neither.

Open reports and the store sheet also say whether the app runs on a phone or a tablet (deviceClass, contract B14): on iOS from Platform.isPad, on Android from the screen's shorter side (600 dp or more = tablet). iPad Safari sends a Mac user agent, so this is the only way Strait can count iPad opens. A custom runtime can supply deviceClass() itself (deviceClassForSize is exported).

Conversion events (revenue on the exact tap)

await strait.trackEvent('purchase', { value: 49.99, currency: 'USD', eventId: order.id });

eventId is your id for the conversion (your order id; 1-64 of A-Z a-z 0-9 _ . : -): Strait stores each one once, so a retry or a second report of the same order never counts twice. Left out, the SDK makes one. An event that can't be sent (offline, server busy) is kept with the saved open reports and sent later, for up to 7 days; trackEvent then resolves false (contract B13a). Refunds are not supported yet. Revenue you must trust (billing, payouts) belongs on your server with the secret key: the app's publishable key is public, so anyone who can guess an order id could send it first.

The event carries the tap id of the last attributed link open for 7 days, so the dashboard can place the revenue on that tap's channel and A/B variant (contracts B15/B16). Every attributed open supplies one: a browser hand-off, a Play install, or (B16) the engine's reply to a verified short link or a deferred match. A newer open replaces the older tap. Pass clickId to set it yourself.

Referral codes (preview, contract B21)

When a deferred link's tap carried a referral code (the link's referralCode, or ?strait_ref= on the tap), the deferred LinkEvent has it as referralCode:

strait.onLink((e) => {
  if (e.kind === 'deferred' && e.referralCode) saveInviter(e.referralCode);
});

It is absent when there is no code, no match, or the engine doesn't send one. Referrals are a preview and are not switched on yet; grant rewards from your server (the referral.converted webhook), not in the app.

User consent / opt-out

If your app asks for consent first (for example under India's DPDP Act or a GDPR consent flow in the EU), call await strait.setEnabled(false) until the person agrees: Strait then sends no open reports or events, does no install matching, and deletes reports waiting on the device. The choice is kept across launches; setEnabled(true) turns it back on (default: on) and isEnabled() reads it. Links that carry their own destination still open your screens. A Strait short link is looked up through the engine's /v1/lookup, which records nothing (no tap, no open, no tap id), so it still opens the right screen; only if that lookup fails (offline) does it come back matched: false, reason: 'disabled'. The legacy resolveDeferredLink honours the same switch when you pass it the same storage: while off it sends nothing (no install) and returns no match. Whether you need consent, and for what, is a question for your own legal advice.

Check your setup (doctor)

Most "the link opens the browser, not the app" reports are setup: the app isn't listed for your link domain, or a secret key is in the app. doctor() checks the key, the endpoint and your link domain's assetlinks.json (Android) / apple-app-site-association (iOS) in one request (contract B23). Call it in debug builds; it never throws. React Native can't read your package or bundle ID without a native module, so pass them (left out, doctor lists what your domain has instead):

if (__DEV__) {
  links.doctor({ androidPackage: 'com.example.app', iosBundleId: 'com.example.app' });
}

You should see (Metro log, Android):

[Strait doctor] ok key.ok: Publishable key is set
[Strait doctor] ok app.listed: hilltop.strait.link lists com.example.app

An error line ends with what to do, e.g. error app.missing: hilltop.strait.link doesn't list com.example.app. Fix: Add com.example.app in Dashboard → Apps & stores (listed now: …). It returns { ok, findings: [{ id, severity, title, fix }] }; ok is false when any finding is an error. On Android, pass certSha256: ['AB:CD:…'] (from ./gradlew signingReport) to also check this build's signing certificate. The free web checker and the docs cover what the app can't see (intent filters, Associated Domains).

How it matches

Platform Method Precision
Android Play Install Referrer (strait_link) deterministic (install_referrer)
Android (no referrer) / iOS with Device matching on server-side device fingerprint probabilistic (exact_ext → exact_core; on iOS also scored when the workspace uses scored matching)
iOS with Paste handoff on one-time handoff link copied by the tap page exact (clipboard)

Every matched deferred LinkEvent also has match, e.g. { method: 'fingerprint', confidence: 'probable' } (exact for install_referrer and clipboard): why this install was attributed. Absent when not matched.

The SDK collects only coarse device fields (screen width, pixel ratio, the device's language tag such as en-US, timezone); the server uses only the first two letters of the language. The server adds the IP it observes and computes the signature — the client never sends an IP, and the signature is never a cross-app identity.

How iPhone install matching works, what is kept (IP only as a keyed hash, for 1 hour) and the App Store privacy label to use: https://straitlink.in/docs/iphone-install-matching/ .

iPhone: you choose the deferred-link method (contract B19)

You make the choice in Dashboard → Settings → iPhone installs, with two switches. It applies at runtime: the SDK reads both switches from Strait's /v1/match reply (ios: {deviceMatching, pasteHandoff}) on the first launch of each install. Nothing is built into your app, so changing it needs no app release, and the SDK never stores it.

  • Device matching: the link page records a few coarse device signals when "Get the app" is tapped, and the first launch matches on them. Apple's guidelines forbid fingerprinting whatever the user's tracking choice, so decide whether this fits your app's App Store review. Off by default for new workspaces (existing workspaces keep their setting).
  • Paste handoff: "Get the app" copies a one-time link (https://<your-handle>.strait.link/h/<token>, single use, 24 h). On the first launch the SDK checks the clipboard and claims it for an exact match. Reading the clipboard shows iOS's "Allow Paste" prompt. Off by default.
Device matching Paste handoff What happens on the first launch
off off No deferred link on iPhone: the app opens its home screen (the install is still counted).
on off Device matching only; the clipboard is never touched.
off on Paste handoff only: the SDK reads the clipboard and claims the link.
on on Device matching first; the clipboard is read only when it finds no match.

For Paste handoff, pass a clipboard adapter (without one the SDK skips that step):

import * as Clipboard from 'expo-clipboard';
import { createStrait, fromExpoClipboard } from '@straitlink/react-native';

const strait = createStrait({
  publishableKey, endpoint,
  clipboard: fromExpoClipboard(Clipboard), // or { hasProbableWebUrl, readText } from @react-native-clipboard/clipboard
});

On the first launch only (iOS only), the SDK always calls /v1/match first (that counts the install, and the reply carries your choice). If it matched, the clipboard is never touched and no prompt shows. The SDK goes on to the clipboard only when the reply arrived, found no match and says pasteHandoff: true. If /v1/match gets no answer (or 429 / 5xx), the choice is unknown, so the clipboard is not touched and the check runs again next launch (reason: 'network'). For the clipboard, the SDK first asks the adapter whether the clipboard probably holds a URL (hasUrlAsync, iOS hasURLs: no prompt). Only if it does does it read the text, and iOS then shows its "Allow Paste" prompt. If the text is a Strait handoff link, the SDK sends just its token to POST /v1/handoff/claim for an exact match (route: 'clipboard'); anything else never leaves the phone, and the SDK keeps the device match result. Both attempts share one openId (one install, one event). To avoid the prompt, show Apple's Paste button (UIPasteControl / SwiftUI PasteButton, e.g. through a small native view) and pass what it pastes to strait.claimHandoff(text).

clipboardBoost is deprecated and ignored: the dashboard decides. You can leave it in your code; it does nothing.

Android: enabling the deterministic path

With createStrait, pass installReferrer: fromPlayInstallReferrer(PlayInstallReferrer) (from react-native-play-install-referrer), as in "Use" above. The standalone resolveDeferredLink doesn't take that option: it looks for a native module named StraitInstallReferrer exposing getInstallReferrer(): Promise<string> (a thin wrapper over Google's InstallReferrerClient) that you provide. With either, Android installs resolve exactly; without one, Android falls back to the fingerprint path automatically.

Store sheet (new)

When a user taps Install for one of your other apps (a sibling, partner or "lite" app), show the app store inside your app and keep the deep link for the app being installed.

const result = await strait.openStoreSheet('https://<handle>.strait.link/promo', {
  callerId: 'com.yourcompany.app', // Android: your own package, needed for the inline sheet
  style: 'product_page',           // iPhone: or 'overlay'
});
// result.method: inline_install | market | web | product_page | overlay | app_store | none
  • Android: Google Play inline install (a half-sheet over your app; Google labels it a test feature), then the Play app, then the Play web page. Each carries referrer=strait_link=<id>&strait_click=<tap>, which the installed app's Play Install Referrer match reads exactly.
  • iPhone: saves this device's match fields for the tap (unless the workspace turned iPhone install matching off), copies the clipboard-boost handoff link when you pass copyHandoffLink: true, then shows the App Store with the link's campaign as the ct token.

The in-app sheet needs a small native module named StraitStoreSheet in your app:

Method Platform Does
openIntent(intent) → Promise<boolean> Android Intent(intent.action, Uri.parse(intent.data)), setPackage(intent.packageName) when set, put intent.extras, startActivity; resolve false on ActivityNotFoundException. sdk-android's README section 5 has the Kotlin.
presentProduct(product, style) → Promise<boolean> iPhone Show SKStoreProductViewController or SKOverlay (sdk-swift's SystemStoreSheetPresenter does this).

Without the module the SDK uses Linking.openURL: the Play app (market://, deep link still kept) or the App Store app (itms-apps://, match still saved), so the user leaves your app. The tap is recorded with sent_to = store_sheet and is not billed while the store sheet is new. It works only where your app is the host: a link tapped inside another company's app can't open a store sheet there.

Testing in non-RN contexts

Inject a fake runtime, storage and fetch (StraitRuntime and KeyValueStore are exported types):

const strait = createStrait({
  publishableKey, endpoint,
  runtime: {
    platform: () => 'ios', collectDevice: () => ({...}), getInstallReferrer: async () => null,
    getInitialURL: async () => null, onURL: () => () => {}, onAppState: () => () => {}, now: () => Date.now(),
  },
  storage: myMemoryStore,
  fetch: myFetchStub,
});

Size and speed

Measured 25 Oct 2026 on 0.9.3. 0.10.0 adds the Expo config plugin (3.1 KB, app.plugin.cjs): it runs at prebuild and is not in your JS bundle.

What Size or time How it was measured
npm package 36.5 KB download, 115 KB unpacked, no dependencies (react-native is a peer) npm pack --dry-run
In your JS bundle 23.3 KB minified, 8.7 KB gzip esbuild 0.25 --bundle --minify of the whole entry (Metro doesn't tree-shake)
Start-up 2.1 ms to load the module + 0.26 ms for createStrait(...) fresh Node 26 process on an M-series Mac with an injected runtime, median of 25. No network: the first-launch check runs after start()

Example app

examples/expo opens a deep link on the right screen, shows the deferred link and logs a purchase event, built against this repo's source with the config plugin. CI type-checks it, runs expo export, checks the plugin's prebuild output and builds it for Android and iOS on every PR.

  1. npm ci here, then cd examples/expo && npm ci; cp .env.example .env and set your publishable key (st_pub_…).
  2. npx expo run:android (or run:ios).
  3. npx uri-scheme open "straitexample://product/42" --android and watch the log on screen.

Docs and support

About

Strait SDK for React Native and Expo: deep links, deferred deep links (first open after install) and link-open analytics. Expo config plugin included.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages