Deferred deep linking for React Native / Expo.
When someone taps your link without the app installed, they go to the App Store or Play Store — and the link's intent is lost. This SDK carries it across that gap: on the first launch after install, your app receives the link that was tapped and can open the right screen.
You bring the server. The SDK is MIT-licensed and speaks one HTTP endpoint,
POST {endpoint}/v1/match, with an opaque bearer token. Point it at your own
backend — PROTOCOL.md specifies everything a server has to do,
and ships with a conformance test you can run against your deployment.
The hosted service at api.uselinking.com is currently offline.
endpointhas no default: pass the origin of a server you run.
npx expo install @uselinking/react-native @react-native-async-storage/async-storageAdd the config plugin to app.json / app.config.js with your link domain —
it sets up the iOS Associated Domains entitlement and the Android autoVerify
intent filter, which is the step most integrations get wrong:
{
"expo": {
"plugins": [
["@uselinking/react-native", { "linkDomain": "links.example.com" }]
]
}
}linkDomain is the host your server serves short links and the
domain-association files from. This changes native config, so rebuild the app
(expo prebuild + a native build, or an EAS build) — an OTA update is not
enough.
import { DeepLinkProvider, useDeferredLink } from '@uselinking/react-native';
export default function App() {
return (
<DeepLinkProvider
config={{
endpoint: 'https://api.example.com', // your server — required
apiKey: process.env.EXPO_PUBLIC_LINK_KEY,
}}
>
<Root />
</DeepLinkProvider>
);
}
function Root() {
const { isLinkProcessed, link, clearLink } = useDeferredLink();
useEffect(() => {
if (!isLinkProcessed || !link) return;
navigate(link.path, link.params); // e.g. "/events/join", { it: "abc123" }
clearLink();
}, [isLinkProcessed, link]);
}Imperative API, for gating startup on the match:
import { init, getInitialLink, waitForInitialLink } from '@uselinking/react-native';
init({ endpoint: 'https://api.example.com', apiKey: '…' });
await waitForInitialLink(5000); // resolves when the attempt settles, or times out
const link = await getInitialLink(); // null on later launchesinterface Config {
endpoint: string; // origin of your server. Required, no default
apiKey: string; // sent as `Authorization: Bearer <apiKey>`; any format
appId?: string; // reserved; not sent on the wire yet
matchTimeoutMs?: number; // per-request budget for /v1/match. Default 5000
}init() throws if endpoint is missing. That is deliberate: without it every
match would quietly return nothing, which looks exactly like "no link was
tapped" — a dead feature you would not notice until someone reported it.
Pointing at a dev server: http://localhost:3000 works from the iOS Simulator,
and the Android emulator reaches the host through http://10.0.2.2:3000.
Your server has three jobs. PROTOCOL.md specifies each one in detail, including the reference matching algorithm:
-
Capture clicks on your link domain. A tap on
https://<linkDomain>/<code>records a pending click — the deep path and params, plus enough about the device to recognise it later — and redirects to the App Store or Play Store. -
Serve domain-association files on that same domain (
/.well-known/apple-app-site-associationand/.well-known/assetlinks.json) so the link opens the app directly once it is installed. -
Answer
POST /v1/match, the only endpoint this SDK calls. On the first launch after install it sends the device's platform, OS version, model, locale and timezone — plus the Play Install Referrer on Android — and your server decides whether a pending click belongs to this install:{ "matchType": "unique", "link": { "url": "…", "path": "/invite/9fb2", "params": {}, "clickId": "…" } }
Android is deterministic: the click id travels through the Play Store referrer.
iOS has no equivalent, so matching is probabilistic (IP bucket + locale +
device class + OS major) and must fail open — return none rather than
risk opening the wrong screen.
test/live-api.e2e.test.ts is the conformance test. Point E2E_ENDPOINT,
E2E_SHORT_URL and E2E_API_KEY at your deployment and it drives a real click
through to a real match, including the consume-once check.
link.matchType tells you how sure the server is, so you can decide what to
trust:
| value | meaning |
|---|---|
unique |
Exactly one pending click matched every signal. Safe to act on. |
weak |
Only one click was pending, but a signal disagreed (e.g. locale). Probably right. |
| — | No confident match: link is null and the app does its normal first launch. |
- One match attempt per install, on the first launch. Later launches resolve from local storage immediately.
- The response is persisted before it is delivered, so a crash during startup doesn't lose the link.
- Network failures settle as "no link" and retry on the next launch — the SDK never blocks or breaks app startup.
- No cross-app tracking, no advertising identifiers. Signals are used once to match your own link, then the pending click is consumed.
MIT