Skip to main content

Universal Links vs App Links

Universal Links (iOS) and App Links (Android) are the modern, platform-native approaches to deep linking. Unlike custom URL schemes, they use standard HTTPS URLs that work as both web links and app deep links, providing a seamless experience across all scenarios.

Quick Comparison

FeatureUniversal Links (iOS)App Links (Android)
PlatformiOS 9+Android 6.0+ (API 23+)
URL Formathttps://yourdomain.com/pathhttps://yourdomain.com/path
Verification Fileapple-app-site-associationassetlinks.json
File Location/.well-known//.well-known/
FallbackOpens Safari if app not installedOpens browser if app not installed
Requires Domain✅ Yes✅ Yes
HTTPS Required✅ Yes✅ Yes
User PromptNo (seamless)No (if verified)

Key Similarity: Both use standard HTTPS URLs that work everywhere

Key Difference: Implementation details and verification processes differ


The Problem with Custom URL Schemes

Old approach (custom schemes):

myapp://products/123

Problems:

  • ❌ Only works if app installed (broken link otherwise)
  • ❌ Scheme conflicts (multiple apps can claim same scheme)
  • ❌ Browser security warnings
  • ❌ Can't use as regular web links
  • ❌ Poor sharing experience

Universal/App Links:

https://go.yourapp.com/products/123

Benefits:

  • ✅ Works as regular web link (shareable everywhere)
  • ✅ If app installed → Opens app
  • ✅ If app NOT installed → Opens website
  • ✅ No broken links ever
  • ✅ No scheme conflicts (you own the domain)
  • ✅ Better user experience
  • ✅ SEO-friendly (search engines index them)

Universal Links are Apple's implementation of HTTPS-based deep linking, introduced in iOS 9 (2015). They allow you to associate your iOS app with your website domain.

1. User clicks: https://go.yourapp.com/products/123

2. iOS checks:
- Is an app associated with "go.yourapp.com"?
- Does that app have Universal Links enabled?

3a. If YES:
→ App opens directly
→ App receives URL: /products/123
→ App routes user to product page

3b. If NO (app not installed):
→ Safari opens
→ Loads: https://go.yourapp.com/products/123
→ Website displays product page

Result: Seamless experience whether app is installed or not


1. Owned Domain

You must own a domain (e.g., go.yourapp.com or yourapp.com)

Options:

  • Use main domain: yourapp.com
  • Use subdomain: go.yourapp.com, link.yourapp.com, app.yourapp.com

Recommendation: Use subdomain dedicated to links (easier to manage)


2. HTTPS Hosting

Your domain must be accessible via HTTPS.

Requirements:

  • ✅ Valid SSL certificate
  • ✅ HTTPS enabled
  • ✅ Publicly accessible (not localhost)

3. Apple App Site Association (AASA) File

A JSON file that tells iOS which app is associated with your domain.

File name: apple-app-site-association (no .json extension)

Location: https://yourdomain.com/.well-known/apple-app-site-association

Example:

{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM1234.com.yourcompany.yourapp",
"paths": [
"/products/*",
"/offers/*",
"NOT /admin/*"
]
}
]
}
}

Components:

  • appID: Team ID + Bundle ID (format: TEAMID.BUNDLEID)
  • paths: Which URL paths should open the app
    • "/products/*" → Matches all product pages
    • "NOT /admin/*" → Excludes admin pages (stay in browser)

4. Associated Domains in Xcode

Enable Associated Domains capability and add your domain.

In Xcode:

  1. Select your project → Target → Signing & Capabilities
  2. Click "+ Capability"
  3. Add "Associated Domains"
  4. Add domain with prefix: applinks:go.yourapp.com

Example:

applinks:go.yourapp.com
applinks:yourapp.com

No HTTPS prefix needed (iOS adds it automatically)


Implement AppDelegate method to receive URLs.

Swift (UIKit):

func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else {
return false
}

// Handle the URL
// url.path will be something like "/products/123"
handleUniversalLink(url: url)
return true
}

func handleUniversalLink(url: URL) {
let path = url.path

if path.hasPrefix("/products/") {
let productID = path.replacingOccurrences(of: "/products/", with: "")
// Navigate to product screen
navigateToProduct(id: productID)
} else if path.hasPrefix("/offers/") {
// Navigate to offers
navigateToOffers()
}
}

SwiftUI:

@main
struct YourApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
handleUniversalLink(url: url)
}
}
}
}

When does iOS download the AASA file?

  • When app is first installed
  • When app is updated
  • Periodically (iOS manages this)

Important:

  • AASA file is cached by iOS (changes can take hours/days to propagate)
  • For testing, uninstall and reinstall app to force re-download

Checklist:

  1. Is AASA file accessible?

    curl https://go.yourapp.com/.well-known/apple-app-site-association
    • Should return JSON (200 OK)
    • No redirects (301/302 not allowed)
    • HTTPS required
    • Correct Content-Type header (application/json or no Content-Type)
  2. Is Team ID correct?

    • Find in Apple Developer account
    • Format: 10 characters (e.g., AB12CD34EF)
    • Common mistake: Using wrong Team ID
  3. Is Bundle ID correct?

    • Must match exactly (case-sensitive)
    • Format: com.yourcompany.yourapp
  4. Is Associated Domains configured in Xcode?

    • Check Signing & Capabilities
    • Should see: applinks:go.yourapp.com
  5. Is app code implemented?

    • Check AppDelegate method exists
    • Add logging to verify it's being called
  6. Is iOS caching old AASA?

    • Uninstall app completely
    • Reinstall from Xcode
    • Test again
  7. Test with Apple's validator


Reasons:

  1. User manually disabled Universal Links

    • When user long-presses a Universal Link, they can choose "Open in Safari"
    • iOS remembers this preference
    • Fix: Uninstall and reinstall app
  2. Link opened from same domain

    • If user is already on go.yourapp.com website
    • And clicks a link to go.yourapp.com/products/123
    • iOS keeps them in Safari (user intent to stay on web)
    • Fix: Use a different domain for website vs links
  3. AASA file has errors

    • Check with Apple's validator
    • Ensure JSON is valid

App Links are Android's implementation of HTTPS-based deep linking, introduced in Android 6.0 (2015). They work similarly to Universal Links but with Android-specific setup.

1. User clicks: https://go.yourapp.com/products/123

2. Android checks:
- Is an app associated with "go.yourapp.com"?
- Is the app verified for this domain?

3a. If YES (verified):
→ App opens directly
→ App receives URL: https://go.yourapp.com/products/123
→ App routes user to product page

3b. If NO (app not installed or not verified):
→ Browser opens
→ Loads: https://go.yourapp.com/products/123
→ Website displays product page

1. Owned Domain

Same as iOS - you must own the domain.


2. HTTPS Hosting

Same as iOS - HTTPS required with valid SSL certificate.


A JSON file that verifies your app is authorized to open links for this domain.

File name: assetlinks.json

Location: https://yourdomain.com/.well-known/assetlinks.json

Example:

[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.yourapp",
"sha256_cert_fingerprints": [
"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"
]
}
}
]

Components:

  • relation: Always ["delegate_permission/common.handle_all_urls"]
  • package_name: Android package name (e.g., com.yourcompany.yourapp)
  • sha256_cert_fingerprints: SHA-256 fingerprint of your app signing key

4. Get SHA-256 Fingerprint

For debug builds:

keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android

For release builds:

keytool -list -v -keystore /path/to/your-release-key.keystore -alias your-key-alias

Output will include:

SHA256: 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

Copy this into assetlinks.json.

Important: Debug and release builds have different fingerprints!


5. Configure AndroidManifest.xml

Add intent filters for your domain URLs.

AndroidManifest.xml:

<activity android:name=".MainActivity">
<!-- Other intent filters... -->

<!-- App Links Intent Filter -->
<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="go.yourapp.com" />
</intent-filter>
</activity>

Key attribute: android:autoVerify="true"

  • Tells Android to verify domain association
  • Without this, user gets prompted to choose browser vs app

Multiple domains:

<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="go.yourapp.com" />
<data android:scheme="https" android:host="yourapp.com" />
</intent-filter>

Kotlin (MainActivity):

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Handle intent
handleIntent(intent)
}

override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
intent?.let { handleIntent(it) }
}

private fun handleIntent(intent: Intent) {
val action = intent.action
val data = intent.data

if (Intent.ACTION_VIEW == action && data != null) {
// data.path will be something like "/products/123"
handleDeepLink(data)
}
}

private fun handleDeepLink(uri: Uri) {
val path = uri.path

when {
path?.startsWith("/products/") == true -> {
val productID = path.replace("/products/", "")
// Navigate to product screen
navigateToProduct(productID)
}
path?.startsWith("/offers/") == true -> {
// Navigate to offers
navigateToOffers()
}
}
}

React Native:

import { Linking } from 'react-native';

useEffect(() => {
// Handle initial URL (app was closed)
Linking.getInitialURL().then(url => {
if (url) {
handleDeepLink(url);
}
});

// Handle URL when app is running
const subscription = Linking.addEventListener('url', ({ url }) => {
handleDeepLink(url);
});

return () => subscription.remove();
}, []);

const handleDeepLink = (url: string) => {
const path = url.replace('https://go.yourapp.com', '');

if (path.startsWith('/products/')) {
const productID = path.replace('/products/', '');
navigation.navigate('Product', { id: productID });
}
};

When does Android verify?

  • When app is installed
  • User can manually verify in Settings

Check verification status:

adb shell pm get-app-links com.yourcompany.yourapp

Output:

com.yourcompany.yourapp:
ID: ...
Signatures: ...
Domain verification state:
go.yourapp.com: verified ← This is what you want

If shows "none" or "legacy":

  • assetlinks.json not accessible
  • SHA-256 fingerprint mismatch
  • android:autoVerify not set to true

Checklist:

  1. Is assetlinks.json accessible?

    curl https://go.yourapp.com/.well-known/assetlinks.json
    • Should return JSON (200 OK)
    • HTTPS required
    • No redirects
  2. Is package name correct?

    • Check in AndroidManifest.xml
    • Must match exactly
  3. Is SHA-256 fingerprint correct?

    • Run keytool command
    • Copy fingerprint to assetlinks.json
    • Common mistake: Using debug fingerprint for release build (or vice versa)
  4. Is android:autoVerify="true" set?

    • Check AndroidManifest.xml intent filter
  5. Force verification:

    adb shell pm verify-app-links --re-verify com.yourcompany.yourapp
  6. Check verification status:

    adb shell pm get-app-links com.yourcompany.yourapp
  7. Test with adb:

    adb shell am start -a android.intent.action.VIEW -d "https://go.yourapp.com/products/123"
    • Should open your app (not browser)

Problem: Disambiguation Dialog Appears

When this happens:

User clicks link → Android shows:
"Open with:"
[ Chrome ]
[ Your App ]

Causes:

  1. android:autoVerify="true" not set
  2. Domain not verified (assetlinks.json issue)
  3. User manually cleared defaults

Fix: Ensure verification completes successfully


1. Verification File Format

Universal Links (AASA):

{
"applinks": {
"apps": [],
"details": [{
"appID": "TEAMID.com.yourcompany.yourapp",
"paths": ["/products/*"]
}]
}
}

App Links (assetlinks):

[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.yourapp",
"sha256_cert_fingerprints": ["FA:C6:17:..."]
}
}]

2. Path Filtering

Universal Links: Can specify which paths open app

"paths": [
"/products/*", // Opens app
"/offers/*", // Opens app
"NOT /admin/*" // Stays in browser
]

App Links: All paths under domain open app (configured in AndroidManifest)

<data android:scheme="https" android:host="go.yourapp.com" />
<!-- All paths under go.yourapp.com/* will try to open app -->

3. Caching

Universal Links:

  • iOS caches AASA file
  • Changes can take hours/days
  • Must uninstall/reinstall to force refresh

App Links:

  • Android verifies on install
  • Can manually re-verify
  • Faster to test changes

4. Debugging

Universal Links:

App Links:

  • Use adb commands to check status
  • Force re-verification
  • Easier to debug

Hosting Both Files

You need BOTH files for cross-platform apps:

https://go.yourapp.com/
.well-known/
apple-app-site-association (iOS)
assetlinks.json (Android)

Static file hosting (recommended):

  • AWS S3 + CloudFront
  • Netlify
  • Vercel
  • GitHub Pages

Web server configuration:

  • Ensure HTTPS enabled
  • No redirects for .well-known paths
  • Correct MIME types (application/json or no Content-Type)

How LinkForty Simplifies This

The Manual Approach is Complex

Without a platform:

  1. Set up domain and HTTPS hosting
  2. Generate AASA file (get Team ID, Bundle ID, paths)
  3. Generate assetlinks.json (get package name, SHA-256 fingerprint)
  4. Host both files at /.well-known/
  5. Configure Xcode (Associated Domains)
  6. Configure AndroidManifest.xml (intent filters)
  7. Implement handling code in both iOS and Android
  8. Test thoroughly on both platforms
  9. Debug issues (often opaque error messages)

Time: 1-2 weeks for first implementation


LinkForty Auto-Generates Verification Files

With LinkForty:

  1. Configure once in dashboard:

    • iOS Team ID: TEAM1234
    • iOS Bundle ID: com.yourapp
    • Android Package Name: com.yourapp
    • Android SHA-256 Fingerprint: FA:C6:17:...
    • Domain: go.yourapp.com
  2. LinkForty automatically generates:

    • /.well-known/apple-app-site-association
    • /.well-known/assetlinks.json
  3. Point your domain to LinkForty:

    • CNAME: go.yourapp.comyour-org.linkforty.app
    • Or use LinkForty's hosting
  4. Implement SDK (handles all platform-specific code):

    import LinkForty from '@linkforty/react-native-sdk';

    await LinkForty.init({
    baseUrl: 'https://api.linkforty.com',
    apiKey: 'your-api-key'
    });

    // LinkForty SDK handles Universal Links + App Links automatically

Time: 15 minutes setup + testing


Best Practices

1. Use Dedicated Subdomain

Don't: Use your main website domain

https://yourapp.com/products/123

Problem: If user is browsing yourapp.com website, links won't open app (iOS keeps them in browser)

Do: Use dedicated subdomain

https://go.yourapp.com/products/123
https://link.yourapp.com/products/123
https://app.yourapp.com/products/123

Benefit: Clear separation between website and app links


2. Keep Verification Files Accessible

Don't:

  • Add authentication to /.well-known/ paths
  • Use redirects (301/302)
  • Change files frequently

Do:

  • Allow public access (no auth)
  • Direct access (no redirects)
  • Keep files stable (changes take time to propagate)

3. Test on Real Devices

Don't: Only test on simulator/emulator

  • Simulators don't fully emulate Universal Links behavior
  • Emulators may have different network setup

Do: Test on real devices

  • iPhone (iOS)
  • Android phone (Android)
  • Test both Wi-Fi and cellular
  • Test with app installed and uninstalled

4. Implement Proper Fallbacks

Always handle:

  • App not installed → Open website
  • URL path not recognized → Show home screen or error
  • Network failure → Cache last known state

5. Monitor Verification Status

iOS: Check with Apple's validator regularly Android: Check verification status with adb


Summary

Universal Links (iOS) and App Links (Android) are the modern standard for mobile deep linking. They use HTTPS URLs that work everywhere, providing seamless experiences whether the app is installed or not.

Key Takeaways:

  • Both platforms use HTTPS URLs (not custom schemes)
  • Require domain ownership and HTTPS hosting
  • Need verification files hosted at /.well-known/
  • Provide graceful fallbacks to web
  • LinkForty auto-generates verification files (saves 1-2 weeks of setup)

Ready to implement?

LinkForty handles all the complexity:

  • ✅ Auto-generates AASA + assetlinks.json files
  • ✅ Cross-platform SDK (handles both iOS and Android)
  • ✅ Testing tools and validation
  • ✅ 15-minute setup vs 1-2 weeks manual

Get Started with LinkForty →

View Implementation Tutorial →


Next Steps

Learn:

Implement:


Questions?