The situation
You are a mobile engineer on a two‑week sprint.
Your team has added a new “Invite” screen that must be reachable from an email link (myapp://invite/abc123). You follow the Expo Router tutorial, add a file app/invite/[code].tsx, run the app in the Expo Go client, tap the link on a device and the screen appears instantly.
During the nightly build, the QA team reports that the same link opened the app on Android but stayed on a blank white screen on iOS, and on Android it sometimes opened the home screen instead of the invite screen. The failure only appears in the production build generated by EAS.
Cost: the release is blocked for another day, the QA cycle is delayed, and the product manager has to explain the missing feature to stakeholders.
The rest of this guide shows why the tutorial‑level configuration breaks under production conditions, and how to make deep links reliable with Expo Router.
Why it happens
Expo Router relies on two mechanisms:
-
Native intent/association handling – Android
intent-filterand iOS Associated Domains tell the OS that a URL scheme or universal link belongs to the app. -
JS‑side route resolution –
expo-routerparses the incoming URL, matches it against the file‑system based route tree, and then renders the matched screen.
The tutorial usually covers only the first step for the development client:
{
"expo": {
"scheme": "myapp"
}
}
In a production build the following hidden constraints appear:
| Constraint | How it manifests | Why it matters |
|---|---|---|
Scheme registration on Android – The generated AndroidManifest.xml only contains a <data android:scheme="myapp"/> if expo.android.scheme is set and the expo-router plugin is applied. Without the plugin the manifest lacks the <intent-filter> block, so Android falls back to the default activity. |
Links open the app but land on the default (Home) screen. |
The router receives null for initialURL. |
Associated Domains on iOS – The iOS project must have an entitlements file with applinks:myapp.com and a matching Apple App Site Association (AASA) file on the domain. The tutorial omits this because Expo Go uses the custom scheme only. |
myapp://invite/abc123 works in development, but a universal link https://myapp.com/invite/abc123 opened from Mail shows a white screen. |
The native bridge receives the URL after the JS runtime has already rendered the home screen, causing a race where the router never processes the deep link. |
Bundle‑time route hashing – Expo Router generates a route manifest (router-manifest.json) at build time. If a file is added after the manifest is generated (e.g., a new [code].tsx added in a later commit without a clean rebuild), the manifest on the device does not contain the new route. |
The app logs Unable to find route for path "/invite/abc123" and stays on a blank screen. |
The router cannot match the incoming URL. |
JS runtime readiness – In production the JavaScript bundle may take 500‑800 ms to evaluate on low‑end devices. If the native side forwards the deep link before router.isReady resolves, the router drops the URL. |
Intermittent failures on Android 6‑7 devices, where the link sometimes works and sometimes does not. | No deterministic behaviour; users perceive the feature as flaky. |
Expo Updates caching – When using expo-updates, a cached bundle may be served that does not contain the latest route files. The deep link will resolve to a missing route. |
QA sees the same link working on a fresh install but failing after a hot‑update. | The update process does not invalidate the route manifest. |
These constraints are not highlighted in the basic tutorial because they only surface when the app is compiled, signed, and run without the Expo Go wrapper.
The fix, step by step
The following procedure assumes you are using Expo SDK 48, expo-router 2.0.0, React Native 0.71.3, and building with EAS Build.
1. Add the router plugin to app.json
{
"expo": {
"name": "MyApp",
"slug": "myapp",
"scheme": "myapp",
"plugins": [
[
"expo-router",
{
"origin": "expo-router"
}
]
]
}
}
Why: The plugin injects the required <intent-filter> on Android and ensures the route manifest is regenerated on every build.
Verify: Run eas build --profile preview --platform android. After the build finishes, download the apk and unzip it (unzip myapp.apk). Open AndroidManifest.xml and confirm the presence of:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
On iOS, open the generated Xcode project (ios/MyApp.xcodeproj) and check that MyApp/Info.plist contains the CFBundleURLTypes entry for myapp.
2. Configure Associated Domains for iOS
- In the Apple Developer portal, enable Associated Domains for the app identifier.
- Add a domain entry
applinks:myapp.com. - Host an
apple-app-site-associationfile athttps://myapp.com/.well-known/apple-app-site-associationwith the following JSON (no.jsonextension,application/jsonMIME type):
{
"applinks": {
"apps": [],
"details": [
{
"appID": "<TEAM_ID>.myapp",
"paths": [ "/invite/*", "/profile/*", "/*" ]
}
]
}
}
- In Xcode, edit the Signing & Capabilities tab, add Associated Domains, and paste
applinks:myapp.com.
Why: Without this, universal links (https://myapp.com/...) are not handed to the app, and the router receives the URL too late.
Verify: Run the app on a physical iOS device, then execute:
xcrun simctl openurl booted "https://myapp.com/invite/abc123"
The console should show:
[expo-router] Initial URL: https://myapp.com/invite/abc123
and the Invite screen should render.
3. Regenerate the route manifest after every change
Whenever you add, rename, or delete a file in the app/ folder, run:
expo prebuild
or, if you rely on EAS, simply trigger a new build. The expo-router plugin automatically runs expo-router generate during the prebuild step.
Why: The manifest is a static snapshot; stale manifests cause missing‑route errors.
Verify: Open the built bundle (.js file) and locate router-manifest.json. Ensure the new route appears, e.g.:
{
"/invite/[code]": {
"file": "app/invite/[code].tsx",
"type": "page"
}
}
4. Delay deep‑link processing until the router is ready
Create a small wrapper component (app/_layout.tsx) that waits for router.isReady before handling the initial URL.
import { useEffect } from 'react';
import { useRouter, usePathname } from 'expo-router';
import * as Linking from 'expo-linking';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
useEffect(() => {
if (!router.isReady) return;
const handle = async () => {
const url = await Linking.getInitialURL();
if (url) {
// Let expo-router parse it; it will navigate automatically.
console.log('[expo-router] Initial URL:', url);
}
};
handle();
}, [router.isReady]);
return <>{children}</>;
}
Why: Guarantees the deep link is processed after the JS bundle has mounted the router.
Verify: On a low‑end Android device, attach adb logcat and look for the log line above when opening a link via:
adb shell am start -W -a android.intent.action.VIEW -d "myapp://invite/abc123"
You should see the log after a short delay, and the Invite screen should appear.
5. Align Expo Updates caching with the route manifest
Add the following to app.json to force a fresh manifest after each OTA update:
{
"expo": {
"updates": {
"fallbackToCacheTimeout": 0,
"checkAutomatically": "ON_LOAD"
}
}
}
Then, in app/_layout.tsx, clear the router cache when a new update is detected:
import * as Updates from 'expo-updates';
import { useRouter } from 'expo-router';
import { useEffect } from 'react';
export default function Layout({ children }) {
const router = useRouter();
useEffect(() => {
const subscription = Updates.addListener(async (event) => {
if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
await Updates.reloadAsync(); // forces a fresh bundle with the correct manifest
}
});
return () => subscription.remove();
}, []);
return <>{children}</>;
}
Why: Guarantees that a newly downloaded bundle also contains the latest route manifest, preventing “missing route” errors after OTA updates.
Verify: Publish an OTA update that adds a new deep‑linkable screen, then install the previous version from the store, open the new link, and confirm the app reloads and displays the screen correctly.
What it costs, and when not to do it
| Cost | Detail |
|---|---|
| Bundle size | Adding the expo-router plugin and the associated native code adds ~30 KB to the Android APK and ~25 KB to the iOS IPA (measured with apktool on SDK 48). |
| Build time | The pre‑build step now runs expo-router generate, adding ~10 seconds to eas build on CI. |
| Maintenance | You must keep the scheme unique across all apps in the Play Store and App Store, and update the AASA file whenever the domain changes. |
| Runtime latency | On low‑end devices the extra Linking.getInitialURL call adds ~100 ms before the first screen renders. This is usually invisible but can be noticeable on Android 5 devices. |
| Complexity | The need to coordinate Android intent filters, iOS associated domains, and the JS manifest introduces three separate failure surfaces. |
When not to use Expo Router deep links
- If your app only needs web‑only navigation (e.g., a PWA), skip native intent configuration.
- If you already use a custom native navigation stack (e.g.,
react-navigationwith native screens) and you want fine‑grained control over intent handling, implement the native side yourself rather than relying on the router plugin. - If you must support legacy Android versions (< 6.0) where the intent‑filter schema is unreliable, consider a fallback to a custom scheme handled by a minimal native module.
At your level
-
Student / first‑year engineer – Verify that the scheme appears in
AndroidManifest.xmland that the iOS entitlements file contains the domain before testing any link. -
Working engineer – Add the router plugin, regenerate the manifest after each route change, and wrap deep‑link handling in a
router.isReadyguard. -
Senior / staff – Document the three‑point contract (scheme, associated domain, manifest regeneration) in the repo’s README, and set up a CI lint step that fails if
router-manifest.jsonis out of sync with the file system. - Lead / director – Allocate budget for the extra build time and for maintaining the AASA file; treat deep‑link reliability as a non‑functional requirement in the product roadmap.
In the interview
Question (as asked by an interviewer):
“Explain how you would implement reliable deep links in an Expo Router‑based React Native app that is built with EAS. What hidden pitfalls would you look for in production?”
Weak answer:
“Just add a scheme to app.json and use Linking.openURL. It works in development, so it should be fine.”
Why it’s weak: It mentions only the obvious step, ignores native intent configuration, the route manifest, and runtime timing issues. No trade‑offs or failure modes are identified.
Strong answer:
- “First, I would add the
expo-routerplugin toapp.jsonso that the Android manifest receives the proper `<intent
Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.











