@sendtoapp/react-native
v0.1.14
Published
Deep links for React Native and Expo: one HTTPS link that opens the right screen, with the config plugin that wires up Universal Links and App Links.
Downloads
399
Maintainers
Readme
@sendtoapp/react-native
One HTTPS link that opens the right screen in your app on iOS and Android, and falls back sensibly when the app is not installed.
npm install @sendtoapp/react-nativeESM only. React Native and Expo bundle it without a thought; a Node script written against
require() cannot consume it, and needs import or a dynamic await import().
Register a link host at sendtoapp.com first — it is the hostname your links are served from, and the one iOS and Android check against your app.
Expo
Add the config plugin. It writes the Android intent filters and the iOS associated domains entitlement, which is the part people usually get wrong by hand.
// app.config.js
export default {
plugins: [
['@sendtoapp/react-native/app.plugin', { host: 'acme.sendtoapp.com' }]
]
};Then rebuild. Not Expo Go — it cannot verify a domain association. A development build or a release build.
Opening a link
With Expo Router, hand it the redirect hook:
import { createDeepLinkClient, DeepLinkError } from '@sendtoapp/react-native';
const client = createDeepLinkClient({
host: 'acme.sendtoapp.com',
// Without this a revoked link, a dead network and a URL that was never yours all arrive as
// the same silent return to '/'. See "Knowing when a link failed".
onLinkError: (error, { path }) => {
// error is typed unknown, because a hook must be able to receive whatever went wrong.
const reason = error instanceof DeepLinkError ? error.code : error;
console.warn('[deep-link]', reason, path);
}
});
export const redirectSystemPath = client.redirectSystemPath;Anywhere else, subscribe to React Native's Linking:
import { Linking } from 'react-native';
import { createDeepLinkClient } from '@sendtoapp/react-native';
const client = createDeepLinkClient({ host: 'acme.sendtoapp.com' });
const stop = client.subscribe(Linking, route => {
// '/products/42?color=blue' — hand it to your navigator
});
// Call stop() on cleanup.subscribe covers both the link tapped while the app is running and the link that launched it,
and it never fires twice for the same one. It ignores URLs that are not yours, so it is safe to
run alongside your own linking code.
Links that arrive before sign-in
The commonest way an integration fails quietly: a link lands, the screen it points at is behind an auth guard, the navigator cannot mount it, and the app opens on its home screen as though nothing arrived.
createPendingLink parks the route until the app can use it.
// app/+native-intent.js
import { createDeepLinkClient, createPendingLink } from '@sendtoapp/react-native';
import { hasSession } from './lib/session';
const client = createDeepLinkClient({ host: 'acme.sendtoapp.com' });
export const pending = createPendingLink();
export async function redirectSystemPath(input) {
const route = await client.redirectSystemPath(input);
if (route !== '/' && !hasSession()) {
pending.set(route);
return '/sign-in';
}
return route;
}// wherever a session first exists
const waiting = pending.consume(Boolean(session));
if (waiting) router.replace(waiting);consume(ready) hands back the route and forgets it, but only when ready is true, so calling
it every render before sign-in is safe. peek() reads without consuming and clear() drops a
parked route on sign-out. The newest link wins.
Knowing when a link failed
redirectSystemPath returns a route to Expo Router, so it cannot throw. Give it onLinkError
and it will tell you why a link did not open instead of quietly sending the user to /.
const client = createDeepLinkClient({
host: 'acme.sendtoapp.com',
onLinkError(error) {
if (!(error instanceof DeepLinkError)) return;
if (error.code === 'UNAVAILABLE') toast('This invite has expired.');
else if (error.code === 'TIMEOUT' || error.code === 'NETWORK') toast('Could not reach the network.');
},
fallbackRoute: '/'
});error.code is one of NOT_FOUND, UNAVAILABLE, TIMEOUT, NETWORK, HTTP or
INVALID_RESPONSE. subscribe() takes the same handler as its third argument.
resolve() behaves differently on purpose: it returns null for a URL that is not yours,
and rejects for one that is yours but could not be resolved. Wrap it in try/catch.
Slow first launches
One attempt gets timeoutMs, 8000 by default. An app's first request also pays for DNS and
a TLS handshake, so it is far slower than every request after it — 1.7–2.2s on a wired desktop
with DNS cached, once 4.7s with nothing wrong, and several times that on a phone waking its
radio. That is exactly when a deep link tends to arrive.
TIMEOUT and NETWORK are retried once; nothing else is, because a 404 answered twice is still
a 404 and twice as slow. A dead-but-open network — one that accepts the connection and never
answers — is bounded at roughly 16s by the timeout. A network that is plainly down fails in
milliseconds and finishes in well under a second.
A NETWORK retry waits 500ms first; a TIMEOUT retry does not, having already spent its
budget waiting. A network failure returns in milliseconds — measured at 5ms with the radios off
— so retrying it instantly lands while whatever broke is still broken. 500 is a floor measured
on an emulator, where a radio came back in about 400ms. Real cellular takes seconds, and this
retry will not rescue that.
The wait a user actually experiences is timeoutMs × (retries + 1), because the case that
hurts is the one where the first attempt runs out. Measured on a marginal network: the first
attempt spent its 8000ms, the retry succeeded in 1.3s, and the link arrived after 9.7 seconds.
createDeepLinkClient({ host: 'acme.sendtoapp.com', timeoutMs: 8000, retries: 1 })onLinkError reports elapsedMs alongside the error, so the wait is in your logs rather than
in a guess. Note what it can and cannot settle: on a TIMEOUT it is the budget you set, not what
the request needed — the timeout is what stopped the clock, so it cannot tell you whether a
larger value would have landed. The way to find that number is to raise timeoutMs and watch
which value stops producing TIMEOUT at all. What elapsedMs does settle is whether the budget
was spent: a NETWORK failure at 300ms and a TIMEOUT at 16000 are different problems with
different fixes.
onRequest settles the other half, the half none of the numbers above can. Every constant here
— 8000, one retry, the 500ms pause — was measured somewhere that is not your users' network, so
the package reports on itself rather than asking you to trust them. It fires once per
resolution, on success as well as failure:
createDeepLinkClient({
host: 'acme.sendtoapp.com',
onRequest: report => console.log('deep_link_request', report)
}){ attempts: 2, ok: true, pausedMs: 500 } is a retry that rescued a launch. { attempts: 2,
ok: false } is one the user waited through for nothing. { attempts: 1, ok: true,
elapsedMs: 7400 } says your budget is nearly spent on a network that is working. A week of
those answers whether the defaults suit your users, which is a question we cannot answer from
here. Anything the hook throws is swallowed; it never changes the route.
report.from says how the link arrived — 'url', 'path' or 'token'. 'path' is the one
worth watching: Expo Router is documented to hand redirectSystemPath a bare in-app path as
well as a full URL, and on a device it has only ever handed over a URL. The branch that handles
the bare path is live code resting on documentation, and your users are the only ones who can
settle whether the OS ever takes it.
React Native CLI
The config plugin is Expo-only. For a bare project, declare the intent filter in
AndroidManifest.xml and the associated domain in your entitlements yourself — the
documentation has both files in full.
Install recovery
If the app was not installed, the visitor goes to the App Store or Google Play. To have the
route survive that trip on Android, add
@sendtoapp/react-native-referrer
or, under Expo, @sendtoapp/expo. Both read
the Play Install Referrer, which needs a native module and so cannot live in this package.
Checking it works
A link that opens the browser instead of your app almost always means the association file does
not match the installed build — most often the Android fingerprint, where the upload key is used
in place of the app signing key. check_association in the
MCP server fetches both files and says what is
wrong, or read Troubleshooting.
Documentation
Full documentation is at sendtoapp.com/docs, and sendtoapp.com is what the platform does in one page.
Quick start · Expo and React Native · Install recovery · Knowing when a link failed · Troubleshooting · HTTP API
The rest of SendToApp
- @sendtoapp/expo — Expo, with Google Play install recovery through the Expo module system
- @sendtoapp/react-native-referrer — Bare React Native, with Google Play install recovery
- @sendtoapp/core — Link resolution with no UI framework attached
- @sendtoapp/mcp — An MCP server, so an agent can register apps and mint links
- @sendtoapp/protocol — The wire format the other packages share
MIT licensed. Built by SendToApp.
