6 min read
Deferred deep linking in Expo Router with +native-intent
How to resolve a deferred deep link in Expo Router before the first screen renders, handle links that arrive late or before sign-in, and test the whole thing in a development build.
A deferred deep link is a link that survives an install. Someone taps myapp.deeplinknow.com/products/123?ref=newsletter, doesn't have the app, goes through the App Store or Play Store, and opens the app for the first time. The app should land on /products/123 with ref=newsletter, not on the home screen.
Expo Router makes the second half of this easy, because every file in app/ already has a URL. app/products/[id].tsx is /products/123, so once you know the path the user tapped, you know the screen. The part that takes some care is when you find out. This guide uses @deeplinknow/react-native 1.3.0, which ships an Expo Router entry point for exactly that.
Why the first screen has to be decided before render
The obvious place to look for a deferred link is a useEffect in app/_layout.tsx: initialize the SDK, ask for the link, router.replace to it. That works, but the layout has already rendered by the time the effect runs, so the app mounts on /, shows the home screen, and then jumps. Anything that runs on the initial route runs against the wrong one: a <Redirect> to onboarding, a protected route's guard, a screen-view event for a screen the user never meant to open.
Expo Router has a hook for this. If app/+native-intent.tsx exports redirectSystemPath, the router calls it with the URL that launched the app and uses whatever it returns as the starting route. It can return a promise, so it can wait on a network call. The Expo Router docs on customizing links describe the function and two rules that apply here: the path argument isn't guaranteed to be a valid path or URL, and the function must not crash. It also doesn't run on web, and it runs outside your app's React tree, so it can't see your auth state.
On a launch with no link, the router passes its own root URL. That is the case where a deferred link might be waiting.
Setup
npx expo install @deeplinknow/react-native
Add the config plugin with your link domains. It writes the iOS Associated Domains entry and an auto-verified Android App Links intent filter for each one:
{
"expo": {
"plugins": [
["@deeplinknow/react-native", { "domains": ["myapp.deeplinknow.com"] }]
]
}
}
The SDK needs Expo SDK 54 or later. Then create app/+native-intent.tsx:
import { createRedirectSystemPath } from "@deeplinknow/react-native/expo-router";
export const redirectSystemPath = createRedirectSystemPath({
apiKey: "dln_pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
});
That function initializes the SDK (you don't call initialize anywhere else), runs the deferred check on the first launch, and returns the tapped path and query, for example /products/123?ref=newsletter. The check is the clipboard on iOS, where the link page copied the URL before sending the user to the App Store, and the Play Install Referrer on Android, with fingerprint matching as the fallback on both. If there's no link, or the check fails, it returns the path it was given and the app starts where it normally would. It never throws, and the reason for a failure is in DeepLinkNow.lastError.
It also handles the installed case. When one of your link domains opens the app directly, as a universal link or App Link, the helper rewrites the full https:// URL to its in-app path, so the same link reaches the same screen whether or not the app was installed. Other URLs, including your own myapp:// scheme, pass through untouched.
If your link paths don't match your file paths, pass mapLink:
export const redirectSystemPath = createRedirectSystemPath({
apiKey: "dln_pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
minConfidence: 50,
mapLink: (link) =>
link.path.startsWith("/p/") ? `/products/${link.path.slice(3)}` : link.fullPath,
});
minConfidence drops fingerprint matches that score below 50 out of 100. It's off by default. Clipboard and referrer results are exact and never filtered.
Links that arrive late
redirectSystemPath blocks the first render, so it can't wait forever. The helper waits at most timeoutMs, 3,000 ms by default, covering both initialization and the check. If the answer comes later, on a slow connection for example, the app has already started on its normal route.
The check doesn't stop at the timeout, though. It keeps running, and a link it finds afterwards is delivered through the useDeferredDeepLink() hook. So the setup is two files, and the second one isn't optional:
// app/_layout.tsx
import { useDeferredDeepLink } from "@deeplinknow/react-native";
import { Stack, useRouter } from "expo-router";
import { useEffect } from "react";
export default function RootLayout() {
const { link, clear } = useDeferredDeepLink();
const router = useRouter();
useEffect(() => {
if (link) {
clear();
router.push(link.fullPath);
}
}, [link]);
return <Stack />;
}
The hook and the helper share one check per JavaScript process, so the network calls happen once. If the helper already routed to the link, the hook returns null. On later launches the native SDK's first-launch flag means the check returns nothing without touching the network, so both files can stay in place permanently.
Links that arrive before sign-in
Suppose the link is /orders/42 and that screen needs a signed-in user. On a fresh install, nobody is signed in. If +native-intent starts the app on /orders/42, your guard sends the user to the sign-in screen and the original path is gone. +native-intent can't check the session first, because it runs outside the app.
The fix is to stop routing deferred links at launch and do it from the layout once the user is signed in. Set timeoutMs: 0, which makes the helper return immediately so every deferred link comes through the hook:
// app/+native-intent.tsx
export const redirectSystemPath = createRedirectSystemPath({
apiKey: "dln_pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
timeoutMs: 0,
});
// app/_layout.tsx
export default function RootLayout() {
const { link, clear } = useDeferredDeepLink();
const { isSignedIn } = useSession(); // your auth hook
const router = useRouter();
useEffect(() => {
if (link && isSignedIn) {
clear();
router.push(link.fullPath);
}
}, [link, isSignedIn]);
return <Stack />;
}
The hook keeps the link in state until you call clear(), so it waits through the sign-in flow and the effect fires when isSignedIn flips. Don't clear it earlier.
The SDK hands a deferred link over once per install. If sign-up can outlast the process, for example an email verification step where the user closes the app and comes back the next day, write link.fullPath to your own storage when it arrives and read it back after sign-in.
Testing it in a development build
Expo Go can't run this end to end. Associated Domains and App Links are tied to your bundle ID and signing, and Expo Go is a different app with its own entitlements, so tapping your link domain will never open Expo Go. Expo Go also doesn't include the SDK's native code. The SDK falls back to a JavaScript implementation that can only do fingerprint matching (no clipboard, no install referrer) and logs a warning saying so. It has no persisted first-launch flag either, so every reload checks again and can route to the same click while it's within the one-hour claim window.
Use a development build: npx expo run:ios or npx expo run:android locally, or eas build --profile development. Then:
- Delete the app from the device.
- Open your link in Safari or Chrome on the device and tap through the link page.
- Install and launch the development build.
On iOS the first launch reads the link from the clipboard. Since iOS 16 that can show the system paste prompt. On Android, a build installed with run:android didn't come from Google Play, so there's no install referrer and the match comes from the fingerprint step. The referrer step needs an install from the Play Store.
The first-launch flag survives reloads in a development build, so a reload won't route again. To run the check again without reinstalling, call DeepLinkNow.resetDeferredDeepLink(), ideally from a debug menu, and restart the app.
The full option list is in the React Native docs, and the React Native and Expo guide covers apps without Expo Router.