| name | deep-links-universal-links |
| description | Implementing and testing deep links on mobile - Android App Links, iOS Universal Links, custom URL schemes, attribution providers, and sunset guidance for Firebase Dynamic Links. Use when adding, debugging, or migrating deep link behavior. |
Deep Links and Universal Links
Instructions
Deep links are the front door of a mobile app. They power marketing, referrals, support links, push notifications, and share flows. Get them wrong and none of the growth loops work.
1. Link Types
| Type | iOS | Android | Properties |
|---|
| Custom scheme | myapp:// | myapp:// | Works without verification, not shareable on the web |
| Universal/App Link | https://app.example.com/... | https://app.example.com/... | Verified domain, opens app if installed, falls back to web |
| Deferred deep link | 3rd-party SDK | 3rd-party SDK | Carries context through install from store |
| Firebase Dynamic Links | deprecated | deprecated | Sunset Aug 25, 2025. Do not build new integrations. |
| Branch / Adjust / AppsFlyer | Supported | Supported | Replacement for FDL, attribution included |
2. iOS Universal Links
- Host an
apple-app-site-association (AASA) JSON file at https://example.com/.well-known/apple-app-site-association, served over HTTPS, no redirects, content-type application/json.
- Add the
Associated Domains entitlement with applinks:example.com.
- Handle the link in
SceneDelegate / SwiftUI.
{
"applinks": {
"details": [{
"appIDs": ["TEAMID.com.example.app"],
"components": [
{ "/": "/article/*" },
{ "/": "/u/*" }
]
}]
}
}
ContentView()
.onOpenURL { url in DeepLinkRouter.shared.handle(url) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { DeepLinkRouter.shared.handle(url) }
}
3. Android App Links
- Declare an intent filter with
android:autoVerify="true".
- Host
assetlinks.json at https://example.com/.well-known/assetlinks.json.
- Verify with
adb shell pm get-app-links com.example.app.
<activity android:name=".MainActivity" android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.example.com" />
<data android:pathPrefix="/article/" />
</intent-filter>
</activity>
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AB:CD:..."]
}
}]
4. Cross-Platform Handling
// Flutter via go_router
final router = GoRouter(routes: [
GoRoute(path: '/article/:id', builder: (_, s) => ArticleScreen(id: s.pathParameters['id']!)),
]);
// plus app_links plugin to catch initial and streamed URIs
Linking.getInitialURL().then((url) => url && router.handle(url));
Linking.addEventListener('url', ({ url }) => router.handle(url));
5. Attribution and Deferred Deep Links
If a link must carry context through a fresh install from the store, the platforms alone do not solve it. Options:
- Branch, AppsFlyer OneLink, Adjust deep links -- full attribution and deferred deep linking.
- Build your own with a clickthrough page that writes a short-lived token to a server and reads it on first launch (by IP+UA fingerprint or a one-time code flow).
Firebase Dynamic Links is sunsetted (August 25, 2025). Migrate to one of the above or roll your own.
6. Routing Contract
All deep links must resolve through a single router:
- Parse the URL into a typed route.
- Reject unknown paths gracefully (land on a sensible screen, log a metric).
- Require auth where appropriate; store the target URL and replay after sign-in.
- Establish a back stack so the system back returns to a reasonable place.
sealed class Route {
data class Article(val id: String) : Route()
data class Profile(val handle: String) : Route()
data object Unknown : Route()
}
fun parse(uri: Uri): Route = when {
uri.pathSegments.firstOrNull() == "article" && uri.pathSegments.size == 2 ->
Route.Article(uri.pathSegments[1])
uri.pathSegments.firstOrNull() == "u" && uri.pathSegments.size == 2 ->
Route.Profile(uri.pathSegments[1])
else -> Route.Unknown
}
7. Testing
- iOS:
xcrun simctl openurl booted https://app.example.com/article/42.
- Android:
adb shell am start -W -a android.intent.action.VIEW -d "https://app.example.com/article/42" com.example.app.
- Custom scheme iOS:
xcrun simctl openurl booted myapp://article/42.
- Custom scheme Android:
adb shell am start -a android.intent.action.VIEW -d "myapp://article/42".
- Always test both cold start and warm start paths. They behave differently.
8. Common Bugs
- AASA not served with the right content-type or blocked by redirects. Verify with
curl -I.
autoVerify failing due to missing assetlinks.json for the release keystore. Both debug and release SHA-256s must be present.
- Links opening the browser instead of the app because the user explicitly disassociated the domain. Provide a manual "open in app" fallback.
- Universal Links failing inside apps that use in-app browsers (WKWebView, SFSafariViewController navigations). Tap-through from Safari to the app is fine; programmatic opens often are not.
- Links arriving before the router is mounted on cold start. Buffer and replay when the navigator is ready.
9. Anti-Patterns
- Using custom schemes for marketing links (unshareable, unverified).
- Embedding user secrets in link paths or query strings.
- Relying on Firebase Dynamic Links for new projects.
- Not versioning the link namespace. Reserve a path prefix like
/l/v1/ so breaking changes are possible.
Checklist