A user clicks a link on Twitter. Instead of opening a web page, your app opens directly to the product detail screen. Another user scans a QR code and lands on the signup page inside your app.
This is what deep links do. They let URLs open specific screens in your app.
In this tutorial, you will learn the difference between deep links and App Links, how to set up both, and how to integrate them with Compose Navigation.
Prerequisites: You should understand Compose Navigation. Read Compose Tutorial #8: Navigation if needed.
Deep Links vs App Links
Android supports three types of links:
| Type | Verification | Disambiguation Dialog | Use Case |
|---|---|---|---|
| Deep link | None | Yes — user picks which app | Custom schemes (myapp://) |
| Web link | None | Yes | http:// and https:// |
| App Link | Verified | No — opens your app directly | Verified https:// links |
Deep links use custom schemes like myapp://product/123. Any app can register the same scheme, so Android shows a disambiguation dialog.
App Links use verified https:// URLs. Android checks a file on your server to confirm that your app owns the domain. No dialog — your app opens directly.
Recommendation: Use App Links for production. Deep links are fine for development and testing.
Setting Up Deep Links
Step 1: Intent Filter
Add an intent filter to your activity in AndroidManifest.xml:
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link: myapp://product/{id} -->
<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"
android:host="product" />
</intent-filter>
<!-- Web link: https://example.com/product/{id} -->
<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="example.com"
android:pathPrefix="/product" />
</intent-filter>
</activity>
Step 2: Handle the Intent
Read the deep link data from the activity’s intent:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp(intent = intent)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Handle deep link when app is already open
}
}
Deep Links with Compose Navigation
Compose Navigation has built-in support for deep links. You define them in your navigation graph.
Type-Safe Routes
Since Navigation 2.8+, you can use @Serializable route classes:
@Serializable
data class ProductRoute(val productId: String)
@Serializable
object HomeRoute
@Serializable
object ProfileRoute
Navigation Graph with Deep Links
@Composable
fun AppNavigation(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = HomeRoute
) {
composable<HomeRoute>(
deepLinks = listOf(
navDeepLink<HomeRoute>(
basePath = "https://example.com"
)
)
) {
HomeScreen(
onProductClick = { productId ->
navController.navigate(ProductRoute(productId))
}
)
}
composable<ProductRoute>(
deepLinks = listOf(
navDeepLink<ProductRoute>(
basePath = "https://example.com/product"
),
navDeepLink<ProductRoute>(
basePath = "myapp://product"
)
)
) { backStackEntry ->
val route = backStackEntry.toRoute<ProductRoute>()
ProductDetailScreen(productId = route.productId)
}
composable<ProfileRoute>(
deepLinks = listOf(
navDeepLink<ProfileRoute>(
basePath = "https://example.com/profile"
)
)
) {
ProfileScreen()
}
}
}
Now these URLs work automatically:
myapp://product/abc123opens the product detail screenhttps://example.com/product/abc123opens the product detail screenhttps://example.com/profileopens the profile screen
Handling Deep Links in the Activity
Pass the intent to the NavController:
@Composable
fun MyApp(intent: Intent?) {
val navController = rememberNavController()
LaunchedEffect(intent) {
intent?.data?.let { uri ->
navController.handleDeepLink(intent)
}
}
AppNavigation(navController = navController)
}
Verified App Links
App Links are the production-ready solution. When a user clicks https://example.com/product/123, your app opens without a disambiguation dialog.
Step 1: Add autoVerify
Set android:autoVerify="true" on your intent filter (already shown above).
Step 2: Create Digital Asset Links File
Create a JSON file at https://example.com/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"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"
]
}
}
]
Getting Your SHA-256 Fingerprint
# Debug keystore
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android
# Release keystore
keytool -list -v -keystore your-release.keystore -alias your-alias
# From Play App Signing (recommended)
# Go to Play Console → Your App → Setup → App signing
# Copy the SHA-256 certificate fingerprint
Step 3: Host the File
The file must be:
- Served over HTTPS
- At exactly
/.well-known/assetlinks.json - Content type:
application/json - Accessible without redirects
- Returning HTTP 200
Example Nginx configuration:
location /.well-known/assetlinks.json {
root /var/www/example.com;
default_type application/json;
}
Step 4: Verify
Use the Statement List Generator tool to test:
# Test with adb
adb shell am start -a android.intent.action.VIEW \
-d "https://example.com/product/123" com.example.myapp
# Check verification status
adb shell pm get-app-links com.example.myapp
Deep Links from Push Notifications
When a push notification should open a specific screen, include the deep link in the notification:
fun showDeepLinkNotification(context: Context, productId: String) {
val deepLinkUri = "https://example.com/product/$productId".toUri()
val intent = Intent(Intent.ACTION_VIEW, deepLinkUri).apply {
setPackage(context.packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, "products")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("New Product Available")
.setContentText("Check out this item!")
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(100, notification)
}
Deep Links from Firebase Cloud Messaging
If you use FCM for push notifications, include the deep link URL in the notification payload:
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val deepLink = remoteMessage.data["deep_link"]
val title = remoteMessage.data["title"] ?: "Notification"
val body = remoteMessage.data["body"] ?: ""
showNotification(title, body, deepLink)
}
private fun showNotification(title: String, body: String, deepLink: String?) {
val intent = if (deepLink != null) {
Intent(Intent.ACTION_VIEW, deepLink.toUri()).apply {
setPackage(packageName)
}
} else {
Intent(this, MainActivity::class.java)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, "messages")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(this).notify(
System.currentTimeMillis().toInt(),
notification
)
}
}
Post-Firebase Dynamic Links
Firebase Dynamic Links shut down in August 2025. If you need deferred deep linking (where the link works even when the app is not installed), you have two options:
Option 1: Native App Links + Play Store Fallback
Use standard App Links. If the app is not installed, the link opens in the browser. On your website, show a “Get the app” banner with a link to the Play Store.
Option 2: Third-Party Services
Services like Branch.io and Adjust provide deferred deep linking:
- User clicks a link
- If the app is not installed, they go to the Play Store
- After installing, the app opens to the deep-linked screen
These services cost money but solve the deferred deep linking problem that Firebase Dynamic Links used to solve for free.
For most apps, Option 1 is enough. Users are used to installing an app and then clicking the link again.
Testing Deep Links
With adb
# Test custom scheme
adb shell am start -a android.intent.action.VIEW \
-d "myapp://product/abc123"
# Test HTTPS link
adb shell am start -a android.intent.action.VIEW \
-d "https://example.com/product/abc123"
# Test with specific package
adb shell am start -a android.intent.action.VIEW \
-d "https://example.com/product/abc123" \
com.example.myapp
With a Test Composable
@Composable
fun DeepLinkTestScreen(navController: NavController) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Deep Link Test", style = MaterialTheme.typography.headlineMedium)
val testLinks = listOf(
"myapp://product/123" to "Product 123",
"myapp://product/456" to "Product 456",
"https://example.com/profile" to "Profile"
)
testLinks.forEach { (link, label) ->
val context = LocalContext.current
OutlinedButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
context.startActivity(intent)
},
modifier = Modifier.fillMaxWidth()
) {
Text(label)
}
}
}
}
Verify App Links Status
# Check if App Links are verified
adb shell pm get-app-links --user cur com.example.myapp
# Reset verification (for testing)
adb shell pm set-app-links --package com.example.myapp 0 all
# Trigger re-verification
adb shell pm verify-app-links --re-verify com.example.myapp
Deep Link Analytics
Track which deep links bring users to your app:
fun trackDeepLink(context: Context, intent: Intent?) {
val uri = intent?.data ?: return
val source = uri.getQueryParameter("utm_source") ?: "unknown"
val medium = uri.getQueryParameter("utm_medium") ?: "unknown"
val campaign = uri.getQueryParameter("utm_campaign") ?: "unknown"
// Log to Firebase Analytics
FirebaseAnalytics.getInstance(context).logEvent("deep_link_opened") {
param("link_url", uri.toString())
param("utm_source", source)
param("utm_medium", medium)
param("utm_campaign", campaign)
}
}
Use UTM parameters in your deep links:
https://example.com/product/123?utm_source=twitter&utm_medium=social&utm_campaign=summer_sale
Privacy Sandbox Awareness
For deep link attribution, be aware that Android’s Privacy Sandbox is changing how tracking works. The Topics API and Attribution Reporting API replace legacy tracking identifiers. If your app relies on deep link attribution for advertising, review the Privacy Sandbox documentation for the latest requirements.
Common Mistakes
1. Forgetting CATEGORY_BROWSABLE
Without android.intent.category.BROWSABLE, links from browsers will not trigger your intent filter. Always include both DEFAULT and BROWSABLE categories.
2. Not Handling onNewIntent
If your app is already open and a deep link arrives, onCreate is not called again. Override onNewIntent in your activity and update the NavController.
3. Wrong assetlinks.json Location
The file must be at /.well-known/assetlinks.json — not /assetlinks.json or any other path. Also check that your server does not redirect HTTPS requests (redirects cause verification to fail).
4. Using Debug Fingerprint in Production
The SHA-256 fingerprint for your debug keystore is different from your release keystore. If you use Play App Signing, use the fingerprint from the Play Console, not your local keystore.
5. Not Testing on Clean Install
Deep link handling can behave differently depending on whether the app was already installed, just installed, or is being opened for the first time. Test all three scenarios.
What’s Next?
In this tutorial, you learned:
- The difference between deep links, web links, and App Links
- Setting up intent filters in the manifest
- Integrating deep links with Compose Navigation and type-safe routes
- Verifying App Links with Digital Asset Links
- Sending deep links from push notifications
- Post-Firebase Dynamic Links alternatives for deferred deep linking
- Testing and verifying deep links with adb
Next up: Android Tutorial #15: Widgets with Glance — where you will learn how to build home screen widgets using Compose-like syntax.
Related Articles
- Jetpack Compose Tutorial #8: Navigation — Compose Navigation basics
- Android Tutorial #5: Multi-Module Architecture — navigation across modules
- Android Tutorial #11: Notifications — deep links from notifications
- Android Tutorial #17: Firebase — FCM push notifications
This is part 14 of the Android Development Tutorial series.