Deep Links and Universal Links
Introduction
Deep Links allow URLs to open your app directly, improving the user experience when sharing content.
Difference between Deep Links and Universal Links
Custom URL Schemes (Basic Deep Links)
- Format:
oneplace://join-event/123/admin - How it works: Always opens the app (if installed)
- Limitations:
- ❌ Doesn't work when shared on WhatsApp and other places (not clickable)
- ❌ Doesn't open web page if app is not installed
- ✅ Easy to implement
- ✅ Works offline
Universal Links (iOS) / App Links (Android)
- Format:
https://yourdomain.com/join-event/123/admin - How it works: Normal HTTPS links that open the app
- Advantages:
- ✅ Works on WhatsApp, email, SMS, anywhere
- ✅ If app not installed, opens in browser
- ✅ Native and professional experience
- ⚠️ Requires server configuration
Prerequisites
- A domain with HTTPS configured
- You'll need to serve configuration files via:
- Backend: Routes that return JSON dynamically
- Static Frontend: .well-known files hosted directly
Information you need to have on hand:
iOS:
- Team ID: Find it in Apple Developer Portal or Xcode
- Bundle ID: Ex:
com.oneplace.app
Android:
- Package Name: Ex:
com.oneplace - SHA256 Fingerprint: Run in terminal:
Copy the SHA-256 from debug AND release
cd android
./gradlew signingReport
React Native App Configuration
1. Define URL Scheme
In app.json file:
{
"expo": {
"scheme": "oneplace"
}
}
2. Configure Linking
File src/app/deepLinks.ts:
import * as Linking from 'expo-linking'
import { LinkingOptions, ParamListBase } from '@react-navigation/native'
export const linking: LinkingOptions<ParamListBase> = {
enabled: 'auto',
prefixes: [
Linking.createURL('/'),
'oneplace://', // Custom scheme
'https://yourdomain.com', // Universal Link
],
}
3. Configure Route in Navigation
In your navigation file (ex: Navigation.tsx):
EventJoin: {
screen: EventJoinPage,
linking: {
path: 'join-event/:id/:permission',
parse: {
id: Number,
permission: String
},
},
options: {
headerShown: false,
}
}
4. Use parameters in screen
// EventJoinPage.tsx
export function EventJoinPage({ route }) {
const eventId = route.params?.id
const permission = route.params?.permission
// Use the parameters...
}
iOS Configuration
1. Add Associated Domains in Xcode
- Open the project:
open ios/YourApp.xcworkspace - Select the main target
- Signing & Capabilities tab
- Click + Capability
- Add Associated Domains
- In the field that appears, add:
Or if using subdomain:
applinks:yourdomain.comapplinks:app.yourdomain.com
IMPORTANT: This configuration must be in ALL targets
2. Verify Entitlements file
Xcode should automatically create/update ios/YourApp/YourApp.entitlements:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:yourdomain.com</string>
</array>
</dict>
</plist>
3. URL Schemes in Info.plist
Already configured automatically by Expo, but verify ios/YourApp/Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>oneplace</string>
</array>
</dict>
</array>
Android Configuration
1. Edit AndroidManifest.xml
File: android/app/src/main/AndroidManifest.xml
Add inside <activity android:name=".MainActivity">:
<!-- Deep Links with Custom Scheme -->
<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="oneplace"/>
</intent-filter>
<!-- App Links with HTTPS (auto-verify is important!) -->
<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="yourdomain.com"
android:pathPrefix="/join-event"/>
</intent-filter>
Note: android:autoVerify="true" makes Android automatically verify the assetlinks.json file on the server. Without it, the user needs to manually choose to open links in the app.
Option 1: Host .well-known files on Static Site
If you have a static site (Wix, WordPress, Netlify, etc.) or an S3/CloudFront bucket, create a .well-known folder in the static files and deploy to make them available via URL.
Required files:
1. Apple App Site Association
Path: /.well-known/apple-app-site-association
{
"applinks": {
"apps": [],
"details": [{
"appID": "TEAM_ID.com.yourapp.bundle",
"paths": ["/join-event/*"]
}]
}
}
Replace:
TEAM_ID: Your Apple Team ID (ex:PF8Z5765JC)com.yourapp.bundle: Your EXACT Bundle ID (ex:com.oneplace.app)
2. Android Asset Links
Path: /.well-known/assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourapp",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99",
"11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF"
]
}
}]
Replace:
com.yourapp: Your Android package name- SHA256 fingerprints: Include BOTH debug AND release (obtained with
./gradlew signingReport)
Option 2: Host via Backend
If you have a backend (Django, Node.js, etc.), you can serve the files via request.
Example: Django/Python
# views.py
from django.http import JsonResponse
from django.views.decorators.cache import cache_control
@cache_control(max_age=3600)
def apple_app_site_association(request):
"""Universal Links configuration for iOS"""
data = {
"applinks": {
"apps": [],
"details": [{
"appID": "PF8Z5765JC.com.oneplace.app", # TEAM_ID.BUNDLE_ID
"paths": ["/join-event/*"]
}]
}
}
return JsonResponse(data, content_type='application/json')
@cache_control(max_age=3600)
def assetlinks(request):
"""App Links configuration for Android"""
data = [{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.oneplace",
"sha256_cert_fingerprints": [
# Debug keystore
"FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C",
# Release keystore
"16:FA:81:E6:BB:9D:21:1E:FB:85:1F:7E:D6:B8:00:2F:7F:94:20:EE:90:64:13:CE:FF:A7:DB:A8:89:58:17:6B"
]
}
}]
return JsonResponse(data, safe=False, content_type='application/json')
# urls.py
from django.urls import path
from . import views
urlpatterns = [
path('.well-known/apple-app-site-association',
views.apple_app_site_association,
name='apple-app-site-association'),
path('.well-known/assetlinks.json',
views.assetlinks,
name='assetlinks'),
]
IMPORTANT: When serving via backend, remember that if the app is not installed, the user will be redirected to the URL in the browser.
Fallback example (Django):
def join_event_fallback(request, event_id, permission):
"""Web page when app is not installed"""
return render(request, 'join_event.html', {
'event_id': event_id,
'permission': permission,
'app_store_link': 'https://apps.apple.com/...',
'play_store_link': 'https://play.google.com/...'
})
Testing Deep Links
1. Rebuild the App (MANDATORY)
Whenever you change native configurations (entitlements, manifest):
# iOS
npx expo run:ios
# Android
npx expo run:android
2. Test Custom URL Scheme
iOS (Simulator):
xcrun simctl openurl booted "oneplace://join-event/123/admin"
Android (Emulator/Device):
adb shell am start -a android.intent.action.VIEW -d "oneplace://join-event/123/admin" com.yourapp
3. Test Universal Links / App Links
iOS (Simulator):
xcrun simctl openurl booted "https://yourdomain.com/join-event/123/admin"
Android:
adb shell am start -a android.intent.action.VIEW -d "https://yourdomain.com/join-event/123/admin"
Physical Device (iOS):
- Open the Notes app
- Create a new note
- Type the link:
https://yourdomain.com/join-event/123/admin - Tap the link
4. Validate configuration
iOS - Apple Validator:
Check the response from these sites:
- https://app-site-association.cdn-apple.com/a/v1/yourdomain.com
- https://branch.io/resources/aasa-validator/ (enter your domain without https://)
IMPORTANT: If Branch.io returns correctly but Apple's CDN doesn't, it's an Apple CDN cache issue. See Common Problems and Solutions → CDN Cache section.
Android - Verify status:
adb shell pm get-app-links com.yourapp
Should show: yourdomain.com: verified
Common Problems and Solutions
1. ❌ Apple CDN Cache
What is Apple's CDN? Apple maintains a CDN (Content Delivery Network) that caches the apple-app-site-association files. When your app tries to validate Universal Links, it queries THIS CDN, not your server directly.
Why does it cause problems?
- You update the file on your server ✅
- But Apple's CDN still has the old version in cache ❌
- Result: App doesn't recognize your links
How to verify:
# Your server (should be correct)
curl https://yourdomain.com/.well-known/apple-app-site-association
# Apple's CDN (may be outdated)
curl https://app-site-association.cdn-apple.com/a/v1/yourdomain.com
Solutions:
A. Wait (1h to 24h)
The cache expires naturally.
B. Change headers (prevent future caching)
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response['Pragma'] = 'no-cache'
response['Expires'] = '0'
C. Consult
Note: You CANNOT manually invalidate Apple's cache.
2. ❌ Incorrect Bundle ID / Package Name
Symptom: Links don't open the app, but .well-known files are correct.
Cause: The Bundle ID in the file doesn't match the app's actual Bundle ID.
Solution:
- iOS: Check in Xcode → Target → General → Bundle Identifier
- Android: Check in
android/app/build.gradle→applicationId - IMPORTANT: Use the EXACT Bundle ID, including suffixes like
.app- ❌ Wrong:
com.oneplacewhen the real one iscom.oneplace.app - ✅ Correct:
com.oneplace.app
- ❌ Wrong:
3. ❌ Incorrect SHA256 Fingerprints (Android)
Symptom: App Links don't work on Android.
Solution:
cd android
./gradlew signingReport
Copy the SHA-256 from debug AND release and add BOTH to assetlinks.json.
4. ❌ Server redirects
Symptom: File exists but iOS doesn't validate.
Cause: Server does redirect (HTTP → HTTPS, www → non-www, etc.)
How to verify:
curl -v https://yourdomain.com/.well-known/apple-app-site-association
Look for: < HTTP/2 301 or < HTTP/2 302
Solution: Configure the server to serve directly, without redirects.
5. ❌ Incorrect Content-Type
Cause: File served as text/plain instead of application/json.
How to verify:
curl -I https://yourdomain.com/.well-known/apple-app-site-association
Solution: Configure the server to send Content-Type: application/json