Deferred Deep Linking in Expo: Setup, Testing, and Pitfalls
Expo deferred deep linking needs a development build, not Expo Go. The app.json setup, the prebuild gap, how to test a real install, and what breaks.
TL;DR: Expo deferred deep linking works, with one hard requirement: a development build, because Expo Go carries a fixed set of native modules and the module that does the matching is not one of them. The mechanism is the same as bare React Native. A tap is recorded before the store takes over, and on first launch a native module sends the device's own signals so the server can match the install back to that click. In Expo the setup is configuration first: install the package with
npx expo install, declare the associated domains and the Android intent filter inapp.json, set the iOS build to dynamic framework linkage, then runnpx expo prebuildand build a development client locally or with EAS. The match arrives as an object in youronLinkcallback withisDeferredset totrue, which is whyexpo-linkingandexpo-routernever see it and you route it yourself once the root layout has mounted. The one real gap is the iOS delegate call for tapped links: there is no WarpLink config plugin, and a prebuild that starts without anios/directory writes theAppDelegatefrom the template, so that call belongs in your ownwithAppDelegateplugin or in a committedios/directory. The deferred check needs neither, which is why an Expo app can attribute installs before it has solved that question.
Why Expo Deferred Deep Linking Needs a Development Build
Every guide to Expo deferred deep linking starts in the same place, so it is worth being precise about why rather than repeating the rule.
Expo Go is a single prebuilt application published to the stores. It loads your JavaScript, and it exposes the native modules that were compiled into it when it was built. That set is fixed. Any library that ships its own native code is not in it and cannot be added at runtime, because adding native code means recompiling the app.
Deferred matching is entirely native work. Reading the Play Install Referrer, reading the vendor identifier on iOS, writing a completion marker that survives a relaunch but not a reinstall, and doing all of that on the genuine first launch before your JavaScript has decided anything, are things a JavaScript module cannot do. So the SDK ships native modules, and the moment you call it inside Expo Go you get the linking error the package throws when the native module is missing:
The package '@warplink/react-native' doesn't seem to be linked. Make sure:
- You have run 'pod install'
- You rebuilt the app after installing the package
- You are not using Expo Go
That third line is the one that matters here. It is not a broken install, and reinstalling node_modules will not change it.
The fix is a development build: your own binary, built from your own project, containing your own native dependencies. You can produce one locally with npx expo run:ios or npx expo run:android, or in the cloud with an EAS development profile. Either way you install that build on the device and develop against it exactly as you did against Expo Go, with the same fast refresh, the same bundler, and the same JavaScript workflow. What changes is that your native dependencies are actually in the binary.
Nothing about this is unique to link handling. It is the same requirement any native module puts on an Expo project, and it is a one time cost. The rest of this guide assumes you have made that switch.
Expo Deep Linking After Install: The Two Halves
Deferred deep linking is the answer to a delivery problem. A user without your app taps your link, the operating system sends them to the App Store or the Play Store, and the destination inside that link goes nowhere. There is no process to route a URL into, so nothing is delivered. When the user finally opens your freshly installed app, no link arrived with it.
So the link is not transported through the install. It is reconstructed on the other side, in two halves.
The first half happens in the browser. When the link is tapped, the redirect service records the click along with what a web request exposes: the address it came from, the browser's preferred language, and the timezone. Then it redirects to the store.
The second half happens on first launch. The native SDK collects the device's own equivalents, the preferred language and the timezone name and offset, plus the platform identifiers that are available without a permission prompt, and sends them to the attribution endpoint. It never sends the address, because an app cannot see its own public one. The server derives that from the request itself, on both the click and the install side, which is the only way the two halves can be compared at all.
What happens next differs by platform, and this is the part worth knowing before you interpret a test result.
On Android, the Play Store carries a referrer string through the install and hands it to the app afterwards. That string names the link, so the match is deterministic: matchType is deterministic, matchConfidence is 1.0, and matchGuaranteed is true. No inference, no time limit.
On iOS there is no equivalent. The App Store passes nothing through an install. The identifier for vendor gives a deterministic answer when the same install asks again, which covers re-engagement rather than a first install, and it is exempt from the App Tracking Transparency prompt. A genuine first install falls through to a probabilistic match built from the address, language, and timezone comparison, starting at 0.85 inside the first hour and decaying from there. The match window is set per link on the server, in the dashboard, with a default of 6 hours and a ceiling of 24.
Both platforms return the same object through the same callback:
interface WarpLinkDeepLink {
linkId: string;
destination: string;
deepLinkUrl: string | null;
customParams: Record<string, unknown>;
isDeferred: boolean;
matchType: 'deterministic' | 'probabilistic' | null;
matchConfidence: number | null;
matchGuaranteed: boolean;
}
The rule that falls out of the two mechanisms: gate anything sensitive on matchGuaranteed, not on a confidence number. Signing a user in, restoring a session, or showing personal data on the strength of a probabilistic match means occasionally showing it to the wrong person, because the fingerprint describes a network rather than a device. Use confidence only for the softer decision of how specific a screen to route to. The cross platform post goes through the full confidence table and the multipliers that reduce it.
expo-linking and expo-router Never See a Deferred Link
This is the single most common confusion in an Expo project, and it looks like a bug for a good reason.
expo-linking wraps the same primitives React Native has always had. getInitialURL() resolves to the URL that launched the app, and useURL() gives you that plus anything delivered while the app is running. Both are reporters. They tell you about URLs the operating system routed into your process, and on the first launch after an install there were none, so getInitialURL() resolves to null and useURL() returns null. Nothing failed.
expo-router sits on the same foundation. Its linking configuration maps URL paths onto your file based routes, which is exactly right for a tapped link and useless for a deferred one, because there is no URL to map. A deferred match is a payload that arrives from a native module over the bridge, seconds after launch, with a link id, a destination, and a confidence score in it. customParams is part of that object too, but it is always empty on a deferred match.
Two consequences follow, and both are practical.
First, an expo-linking deferred integration is not a thing you configure. You do not extend the linking config, add a prefix, or register a scheme to make a first launch match arrive. You subscribe to the SDK callback, and it hands you an object.
Second, that object usually arrives before your navigator is ready. The deferred check is fired by configure(), which you call at module scope so it runs as early as possible, and on the first launch after an install it commonly resolves while the root layout is still mounting. Calling router.push() at that moment is refused, because expo-router will not navigate before the root layout has mounted. The fix is not to delay configure(), which would only make the check later and the match weaker. The fix is to hold the link and flush it when navigation is ready, which is what the setup below does.
Setup in an Expo Project
Six steps, in order. Nothing here needs you to eject or to abandon the managed configuration workflow.
1. Install the package
npx expo install @warplink/react-native
Use npx expo install rather than a plain package manager install so the version is resolved against your Expo SDK version.
2. Declare your link domains in app.json
iOS needs the associated domains entitlement and Android needs an intent filter. Both are written into the native projects from app.json on every prebuild, so they survive regeneration:
{
"expo": {
"ios": {
"associatedDomains": ["applinks:aplnk.to"]
},
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "aplnk.to" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
If your links are served from your own domain, add it alongside aplnk.to in both places.
3. Turn on dynamic framework linkage for iOS
The iOS side of the React Native package pulls the native iOS SDK in through Swift Package Manager, which requires dynamic framework linkage in the Podfile. In a bare project you would add that line to ios/Podfile yourself. In an Expo project the Podfile is generated on every prebuild, so you set a build property instead and let prebuild write it:
npx expo install expo-build-properties
{
"expo": {
"plugins": [
["expo-build-properties", { "ios": { "useFrameworks": "dynamic" } }]
]
}
}
After the next prebuild, confirm it landed by opening ios/Podfile.properties.json and looking for "ios.useFrameworks": "dynamic". That JSON file is what expo-build-properties writes, and the generated ios/Podfile only reads it: its use_frameworks! line is present in every Expo project whether or not the property is set, so the Podfile itself is not the check. This is the step most often missed in an Expo project, because in a bare project it is a manual edit that stays edited, and here it is a property that has to be declared before the file exists.
4. Generate the native projects and build a development client
npx expo prebuild
npx expo run:ios
Use npx expo run:android for Android, or an EAS build with a development profile if you would rather build in the cloud. Install the result on the device or simulator you are going to test with. From here on, "the app" means that build, not Expo Go.
5. Configure the SDK at startup
Call configure() once, at module scope, so it runs before the first render rather than inside a component effect. The credential is an SDK key, created in the dashboard under API Keys, then SDK key. An ordinary API key looks identical and passes the format check, but it cannot record installs, so deep links keep resolving while every attribution call is rejected.
// warplink.ts
import { WarpLink, type WarpLinkDeepLink } from '@warplink/react-native';
type LinkHandler = (link: WarpLinkDeepLink) => void;
let handler: LinkHandler | null = null;
let pending: WarpLinkDeepLink | null = null;
function deliver(link: WarpLinkDeepLink): void {
if (handler === null) {
// The router is not ready yet. Hold it for the root layout to flush.
pending = link;
return;
}
handler(link);
}
export function setLinkHandler(next: LinkHandler): void {
handler = next;
if (pending !== null) {
const link = pending;
pending = null;
next(link);
}
}
WarpLink.configure({
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
debugLogging: true,
onLink: ({ deepLink, error }) => {
if (error !== undefined) {
console.warn('WarpLink error:', error.code, error.message);
return;
}
if (deepLink !== undefined) {
deliver(deepLink);
}
},
}).catch((configureError: unknown) => {
console.warn('WarpLink configure failed:', configureError);
});
Three details in that file are worth reading twice. configure() does not throw on a malformed key: it reports one through onLink as an { error } event with code E_INVALID_API_KEY_FORMAT and leaves the SDK unconfigured. An exception thrown inside your own onLink propagates out of the promise configure() returns, which is why the .catch() is there, and on a first launch a navigation call is the most likely thing in that callback to throw. And omitting onLink does not switch the deferred check off, because that request is what attributes the install. Only automaticDeferredDeepLinks: false stops it.
If you serve links from a custom domain, declare it so the SDK recognizes it on the very first launch, before it has fetched your domain list:
WarpLink.configure({
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
linkDomains: ['links.yourapp.com'],
onLink: ({ deepLink }) => deepLink !== undefined && deliver(deepLink),
});
The same declaration can go in the native project instead, where it is read before any JavaScript runs. In Expo the iOS half of that is reachable from app.json through expo.ios.infoPlist as a WarpLinkDomains array, and the Android half, an app.warplink.DOMAINS meta-data entry, needs a config plugin. Passing linkDomains to configure() is the simpler route and is enough for a JavaScript first app.
6. Route the match after the router has mounted
// app/_layout.tsx
import { useEffect } from 'react';
import { Stack, useRouter } from 'expo-router';
import type { WarpLinkDeepLink } from '@warplink/react-native';
import { setLinkHandler } from '@/warplink';
/** One query value off the link's own deep link URL, percent-decoded. */
function readParam(url: string | null, key: string): string | null {
if (url === null) return null;
const query = (url.split('#')[0] ?? '').split('?')[1] ?? '';
for (const pair of query.split('&')) {
const eq = pair.indexOf('=');
const rawKey = eq === -1 ? pair : pair.slice(0, eq);
const rawValue = eq === -1 ? '' : pair.slice(eq + 1);
if (decodeURIComponent(rawKey) !== key) continue;
const value = decodeURIComponent(rawValue.replace(/\+/g, ' '));
return value === '' ? null : value;
}
return null;
}
export default function RootLayout(): React.JSX.Element {
const router = useRouter();
useEffect(() => {
setLinkHandler((link: WarpLinkDeepLink) => {
const productId = readParam(link.deepLinkUrl, 'product_id');
// A guess about a network, not a person. Keep it to public content.
const confident = link.matchGuaranteed || (link.matchConfidence ?? 0) > 0.5;
if (productId !== null && confident) {
router.push({ pathname: '/product/[id]', params: { id: productId } });
return;
}
router.push('/welcome');
});
}, [router]);
return <Stack />;
}
Importing @/warplink from the root layout is what runs configure(), so there is no separate initialization call to forget. The effect registers the handler after the layout has mounted, and setLinkHandler flushes anything that arrived in the meantime, which on a first launch is usually the deferred match itself.
The value comes off deepLinkUrl, the link's own per-platform deep link URL returned verbatim, so whatever you wrote into that URL when you created the link is what arrives here. It is string | null and a query string is untyped text, so check for null and narrow each value before you navigate with it.
The Prebuild Gap: The iOS Delegate Call
There is one part of a full link integration that app.json cannot express today, and being clear about it is more useful than working around it badly.
iOS does not hand Universal Links to native modules on its own. The AppDelegate receives them, and it has to forward each incoming URL to the SDK. In a bare project you add two calls to a file you own. In an Expo project that file is generated, and how long a hand edit to it lasts depends on how prebuild runs. A plain npx expo prebuild layers its output over what is already in ios/, so an edit made locally is usually still there afterwards, which is the reassuring case. It is gone as soon as prebuild starts from scratch: npx expo prebuild --clean deletes the native directories first, and a machine that has no committed ios/ directory, which is every cloud build and every fresh clone, generates the file from the template with no WarpLink call in it. There is no WarpLink config plugin to put it back.
The SDK documentation gives two supported answers.
Commit the generated native directory. Run npx expo prebuild, remove ios/ from your .gitignore if the default is still there, commit the result, and add the calls to the AppDelegate in it. You keep every other Expo convenience and give up regeneration for that platform.
Write a small config plugin. Around forty lines, applied on every prebuild, so the managed workflow stays intact:
// plugins/with-warplink-app-delegate.js
const { withAppDelegate } = require('expo/config-plugins');
const IMPORT_LINE = 'import warplink_react_native\n';
const ANCHOR = 'continue userActivity: NSUserActivity,';
const CALL = [
'',
' if userActivity.activityType == NSUserActivityTypeBrowsingWeb,',
' let url = userActivity.webpageURL {',
' WarpLinkModule.handleIncomingURL(url)',
' }',
].join('\n');
module.exports = function withWarpLinkAppDelegate(config) {
return withAppDelegate(config, (mod) => {
const file = mod.modResults;
if (file.language !== 'swift') {
throw new Error(`WarpLink: expected a Swift AppDelegate, found ${file.language}.`);
}
if (file.contents.includes('WarpLinkModule.handleIncomingURL')) {
return mod;
}
const anchorAt = file.contents.indexOf(ANCHOR);
if (anchorAt === -1) {
throw new Error(
'WarpLink: continue userActivity method not found. Open the generated ' +
'AppDelegate and update ANCHOR to match it.'
);
}
const bodyAt = file.contents.indexOf('{', anchorAt);
const importAt = file.contents.indexOf('import ');
// Insert the call first, then the import: the import offset sits before the
// call site, so it is still valid after the later insertion.
file.contents =
file.contents.slice(0, bodyAt + 1) + CALL + file.contents.slice(bodyAt + 1);
file.contents =
file.contents.slice(0, importAt) + IMPORT_LINE + file.contents.slice(importAt);
return mod;
});
};
Register it after your other plugins:
{
"expo": {
"plugins": [
["expo-build-properties", { "ios": { "useFrameworks": "dynamic" } }],
"./plugins/with-warplink-app-delegate"
]
}
}
The plugin throws rather than silently doing nothing when the template text has moved, which is deliberate. A patch that quietly fails to apply is how a project ends up with links that worked in June and stopped in September for no visible reason.
Two things bound how much this blocks you. handleIncomingURL returns nothing and does no domain filtering, so never let it decide the delegate's return value: keep returning whatever your existing linking code returned, or the links your app already handles stop arriving. And the deferred check does not use this path at all. It runs from configure() straight to the attribution endpoint, so an Expo app that has not written the plugin yet still attributes installs and still receives deferred matches. What it misses is the tap from a user who already has the app.
Android needs no host code, provided the launch activity uses android:launchMode="singleTask", which is what lets a warm start intent reach the SDK instead of creating a second activity. Check the generated android/app/src/main/AndroidManifest.xml after a prebuild rather than assuming, and if it needs changing, a withAndroidManifest plugin or a committed android/ directory both work.
Testing Deferred Deep Links in an Expo Project
The rules that are not specific to Expo hold here unchanged, and the cross platform testing section has them: the check runs once per install, TestFlight is the iOS route to a real first install, and a Play internal testing track install is the only way to see the deterministic Android path. Three things change when the app under test is an Expo development build.
npx expo run:ios reinstalls over the app, it does not replace it. Running it again on a device or simulator that already has the build leaves the app container in place, and with it the completion marker that says the deferred check already ran. The check then resolves from its cache with no network request, which looks exactly like a match that failed. Delete the app between attempts, or erase the simulator entirely (Device, then Erase All Content and Settings, or xcrun simctl erase) when you want the first install path rather than the reinstall path.
An EAS internal distribution build is not a Play install. Handing a development build to a tester through internal distribution, or installing a locally built APK with adb install, bypasses the Play Store, so no install referrer is attached and Android falls back to the same fingerprint iOS uses. That is a real test of the fallback branch and no test at all of the deterministic one. When you want to see matchType of deterministic on Android, the build has to arrive through an internal testing track in the Play Console.
Test in the development build, every time. An Expo project can still be opened in Expo Go from the same dev server, and it will start, load your JavaScript, and fail at the first SDK call. If a deferred test reports the native module as unlinked, check which client is running before you check anything else.
Pitfalls Specific to Expo
- Testing in Expo Go. The native module cannot load, and the error message says so. Every deferred test needs a development build.
- The framework linkage property was never set. Without dynamic framework linkage the iOS build fails at the pod install or link step rather than at runtime, which sends people looking in the wrong place. Set the build property before the first iOS build, not after.
- An AppDelegate edit that only exists on your machine. A plain prebuild layers over the existing
ios/directory, so the edit keeps working locally and is then missing from the first cloud build. Tapped Universal Links stop opening the app while deferred matching carries on working, which is a confusing pair of symptoms until you know they use different paths. npx expo prebuild --cleanon a project with a committed native directory. The clean flag deletes the native directories before regenerating them, so it discards theAppDelegateedits a plain prebuild would have left alone. Pick the plugin or the committed directory and stay with it.- Waiting for
expo-routerto deliver the link. It never will. A deferred match is not a URL and appears in neither the linking config noruseURL(). - Navigating from
onLinkdirectly. On a first launch that call often lands before the root layout has mounted and is refused. Hold the link and flush it from an effect.
Those are the ones Expo adds. The failures that have nothing to do with Expo, an API key used where an SDK key was needed, an offline first launch, and a match that only ever arrives once per install, are in the cross platform checklist.
Frequently Asked Questions
Can I test deferred deep links in Expo Go?
No. Expo Go is a prebuilt app that carries a fixed set of native modules, and deferred matching runs inside a native module that is not one of them. Calling the SDK in Expo Go raises a linking error that names Expo Go as a likely cause. Run npx expo prebuild followed by npx expo run:ios or npx expo run:android, or build a development client with EAS, and test in that build instead.
Do I need an Expo config plugin to use WarpLink?
Not for the deferred check. There is no WarpLink config plugin, and none is needed for first launch matching, because that check runs from configure() in JavaScript and touches no native file. You need a plugin, or a committed ios/ directory, only for tapped Universal Links on iOS, where the AppDelegate has to forward the incoming URL to the SDK. A prebuild that starts without an ios/ directory, which is what --clean and a cloud build both do, writes that file from the template with no call in it.
Why does expo-router never receive the deferred link?
Because a deferred match is not a URL. expo-linking and the expo-router linking config both report URLs the operating system handed your app, and a fresh install was handed nothing. The match arrives later as a plain object in the onLink callback with isDeferred set to true, so you route it yourself once the root layout has mounted.
Does npx expo prebuild delete my WarpLink setup?
Only the parts that live in native files, and only when prebuild starts from scratch. Everything declared in app.json, including the associated domains, the Android intent filter, and the build properties, is written back on every prebuild. A plain npx expo prebuild layers its output over the existing native projects, so a hand edit to ios/AppDelegate.swift usually survives locally, but it does not survive npx expo prebuild --clean or a build machine that starts without an ios/ directory. Move that edit into a config plugin or commit the generated ios/ directory.
Will a development build from EAS give me an exact Android match?
No, and that is expected. The Play Install Referrer is attached by the Play Store during the install, so an internal distribution build or an adb install arrives without one and falls back to the probabilistic fingerprint. Use an internal testing track install from Play when you want to exercise the deterministic path your real users get.
Does the deferred check need the associated domains entitlement? No. The entitlement and the Android intent filter decide whether a tapped link opens your app instead of a browser page. The deferred check is an outbound request the SDK makes on first launch, so it works before either is verified. You still want both, or the users who already have the app installed get the web page rather than the screen.
Related Guides
- The cross platform mechanism in full: Deferred Deep Linking in React Native covers the confidence table, the manual check, the testing checklist, and the React Navigation wiring that sits under
expo-router. - Tapped links first: React Native Deep Linking is the setup this post builds on, from the entitlement to the intent filter.
- Reference: the React Native SDK guide and the deferred deep links reference.
- The SDKs themselves: WarpLink SDKs for what each one ships, what it sends, and how large it is.
How WarpLink Helps
Everything above is mechanism, and the mechanism is the same whoever runs it. What an Expo app cannot host inside itself are the two halves that live outside it: the click recorder that captures the signals at the moment of the tap, before the store takes over, and the attribution endpoint that derives the address from the request and walks the match cascade. WarpLink is link infrastructure for those two halves, and the React Native SDK is the bridge from them into your JavaScript. In an Expo project it comes down to npx expo install, three entries in app.json, a development build, and one WarpLink.configure({ apiKey, onLink }) call. The SDK is MIT licensed with no third party runtime dependencies, so the bridge is a handful of files you can read.
The part worth stating plainly is that the same match does two jobs. It routes the user to the screen the link promised, and it tells you which link, which campaign, and which share drove that install, because linkId, deepLinkUrl, matchType, matchConfidence, and matchGuaranteed all come back in one payload. That is deep linking and install attribution answered by a single call, feeding the real time analytics that show which taps became users, from one Expo codebase on both platforms.
Create a free WarpLink account to get all three, with 10,000 clicks a month on the free tier and no time limit. The React Native SDK docs have the complete setup, including the Expo notes referenced above.
WarpLink Team
Building affordable, reliable link infrastructure for mobile teams. Deep linking, install attribution, and real-time analytics in one SDK.
Related Posts
Deferred Deep Linking in React Native: One API for iOS and Android
Deferred deep linking in React Native routes the pre-install link on first launch through one JavaScript API, backed by the Play referrer and iOS matching.
Deferred Deep Linking on Android: How to Implement It in Kotlin
Deferred deep linking on Android hands the tapped link to the app on first launch through the Play Install Referrer, with fingerprint matching as the fallback.
QR Code Deep Linking: Route Scans Into Your App, Even Before Install
A QR code deep link is an HTTPS URL that a scan opens with a real user tap, so it must point at a redirect you control. How to route and attribute every scan.