Carry the user to the right screen in one tap
You send a push notification, the user taps it — and lands on the home screen. The message said "Today's wallpaper is ready," but the wallpaper itself is still several taps away, and most people give up before they get there. Running wallpaper apps, this was a leak I watched happen far too often.
Deep links close that distance to zero. Tapping a URL like myapp://wallpaper/sky-blue launches the app and opens that wallpaper's detail screen directly. Whether the entry point is a notification, a website, another app, or a tracked marketing link, a deep link carries the user straight to the one screen you meant.
This guide walks through implementing both URL Schemes and Universal Links in a Rork Max (Expo-based) app, and the snags you only discover on a real device.
URL Schemes vs. Universal Links — which, and when
The two methods play different roles. A URL Scheme (myapp://path) is the easiest to set up and is ideal for in-app testing and inter-app calls. The catch: if the app isn't installed, the tap does nothing, and to the user the link simply looks broken.
Universal Links (iOS) and App Links (Android) both use ordinary https://myapp.com/path web URLs. If the app is installed, the URL opens the app; if not, it opens the web page instead. That seamless fallback is the whole point, and it's the approach Apple officially recommends. For any URL you hand to users — notifications, campaigns — I default to Universal Links / App Links.
In practice the split is simple: use URL Schemes for internal flows and debugging, and Universal Links for anything a user will tap. Keep both wired up and you rarely have to think about it.
Declare your scheme and associated domains in app.config
Projects generated by Rork Max are Expo-based, so you declare your entry points in app.json (or app.config.ts).
{
"expo": {
"scheme": "myapp",
"ios": {
"associatedDomains": ["applinks:myapp.com"]
},
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{ "scheme": "https", "host": "myapp.com", "pathPrefix": "/wallpaper" }
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}scheme registers the URL Scheme (myapp://), while associatedDomains and intentFilters declare your Universal Links / App Links. So far this is all config-file work, and almost nobody trips here. The real problems live on the server side.
Where to look first when Universal Links "don't work"
"I added every setting, but tapping the link still opens Safari." That's the most common Universal Links symptom, and the cause is almost always the server-side verification file, not your app code.
iOS fetches a file at https://myapp.com/.well-known/apple-app-site-association (the AASA) for each declared domain, and only opens the app once the contents match. Three failures dominate: the file isn't served under /.well-known/, the Content-Type isn't application/json, or there's a redirect (301/302) in the way. Any one of those and iOS quietly gives up and opens the URL as a plain web link in Safari.
A correct AASA returns this JSON with a clean 200 and no redirect:
{
"applinks": {
"details": [
{
"appIDs": ["YOUR_TEAM_ID.com.example.myapp"],
"components": [
{ "/": "/wallpaper/*", "comment": "links into wallpaper detail" }
]
}
]
}
}Android reads https://myapp.com/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": ["YOUR_SHA256_FINGERPRINT"]
}
}
]The line that caught me out was sha256_cert_fingerprints. If you ship a production AAB while leaving your local debug certificate's fingerprint in place, it won't match the Play signing key and App Links never verify. For production, use the SHA-256 shown under "App signing" in the Play Console — an unglamorous detail that matters. After deploying either file, I always open its URL in a browser to confirm it returns raw JSON with no redirect.
React Navigation integration and the cold-start trap
Once the entry points resolve, you map URLs to screens. You can ask Rork Max in plain language:
"Add these deep links to the app:
- myapp://home → Home screen
- myapp://wallpaper/:id → Wallpaper Detail screen
- myapp://profile → Profile screen
Implement it using React Navigation's linking config."
The generated linking config looks roughly like this:
const linking = {
prefixes: ['myapp://', 'https://myapp.com'],
config: {
screens: {
Home: 'home',
WallpaperDetail: 'wallpaper/:id',
Profile: 'profile',
},
},
};
<NavigationContainer linking={linking}>
{/* ... */}
</NavigationContainer>Here's the trap you only feel on a device. There are two paths for receiving a link: when the app is already running (warm), Linking.addEventListener('url', ...) fires; but when the app launches from a fully terminated (cold) state, you'll miss the first URL unless you also read it with Linking.getInitialURL().
That's exactly what bit me in a wallpaper app. I'd written only addEventListener by hand, so tapping the notification while the app was killed landed on the home screen every time — never the wallpaper screen I intended.
// Before: only catches warm launches, misses cold start
import * as Linking from 'expo-linking';
useEffect(() => {
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url));
return () => sub.remove();
}, []);// After: explicitly read the launch URL too
import * as Linking from 'expo-linking';
useEffect(() => {
// Cold start: the URL the app was opened with
Linking.getInitialURL().then((url) => {
if (url) handleUrl(url);
});
// Warm: navigation while running
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url));
return () => sub.remove();
}, []);The good news: if you use React Navigation's linking config, it reads getInitialURL internally, so both paths are handled for you. The flip side — you only need to catch the cold start yourself when you bypass the linking config and handle URLs manually. Keep that distinction in mind and the confusion disappears.
Route from a push notification to a specific screen
Finally, let's fix the "tap the notification, land on home" problem from the opening. Put the deep link URL in the notification's data payload, then open it on tap.
// Server side: include the destination URL in the notification data
const notification = {
title: "Today's wallpaper is ready",
body: "Added a new sky gradient",
data: { url: "myapp://wallpaper/sky-blue" }
};
// App side: open the URL on tap
import * as Notifications from 'expo-notifications';
Notifications.addNotificationResponseReceivedListener((response) => {
const url = response.notification.request.content.data?.url;
if (url) Linking.openURL(url);
});Calling Linking.openURL hands the URL to the linking config you defined, which carries it all the way to the wallpaper detail screen. After years of running small apps, I've come to believe that where the tap lands matters more for retention than the open rate itself.
Your next step
Start with just the URL Scheme: run npx uri-scheme open "myapp://wallpaper/sky-blue" --ios from your terminal against the simulator and confirm it lands on the screen you intended. Once that path works, wiring up AASA / assetlinks for Universal Links is a short, continuous step from there.