The Jetpack Navigation component handles the mechanics of fragment transactions, back stack management, and argument passing. It also has first-class support for deep links, which means you can define your URL patterns in the nav graph XML and let Navigation handle routing rather than writing manual intent parsing code.
This tutorial walks through setting up deep links with the Navigation component from scratch, covering the nav graph definitions, Activity setup, argument passing, nested graphs, and testing. It assumes you have already verified your App Links (the assetlinks.json file is in place and Android has confirmed the association). If not, start with our Android App Links developer documentation first.
Project Setup
Add the Navigation component dependencies to your build.gradle:
dependencies {
val navVersion = "2.7.7"
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
}
The Navigation component uses a nav graph XML file to define your destinations and the connections between them. Create one at res/navigation/nav_graph.xml if you do not already have one.
Defining Deep Links in the Nav Graph
A deep link in the nav graph is declared as a child element of a destination. This is called an implicit deep link because it maps incoming URLs to destinations without code.
<?xml version="1.0" encoding="utf-8"?>
<navigation
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_graph"
app:startDestination="@id/homeFragment">
<fragment
android:id="@+id/homeFragment"
android:name="com.example.app.HomeFragment"
android:label="Home" />
<fragment
android:id="@+id/productDetailFragment"
android:name="com.example.app.ProductDetailFragment"
android:label="Product">
<argument
android:name="productId"
app:argType="string" />
<deepLink
android:id="@+id/productDeepLink"
app:uri="https://yourdomain.com/products/{productId}" />
</fragment>
<fragment
android:id="@+id/checkoutFragment"
android:name="com.example.app.CheckoutFragment"
android:label="Checkout">
<argument
android:name="cartToken"
app:argType="string" />
<argument
android:name="promoCode"
app:argType="string"
android:defaultValue="" />
<deepLink
android:id="@+id/checkoutDeepLink"
app:uri="https://yourdomain.com/checkout?token={cartToken}&promo={promoCode}" />
</fragment>
</navigation>
The {productId} syntax in the URI template is a placeholder. Navigation extracts the value from the actual URL and passes it as a navigation argument with the same name. For path parameters like /products/{productId}, the value is extracted from the URL path. For query parameters like ?token={cartToken}, the value is extracted from the query string.
Note the & encoding for & in XML. This is required in XML attribute values.
Activity Setup
Your Activity needs to connect the NavController to incoming intents. Add the nav graph to your AndroidManifest.xml:
<activity android:name=".MainActivity">
<nav-graph android:value="@navigation/nav_graph" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
The <nav-graph> element tells the build system to read your nav graph and generate the corresponding <intent-filter> entries in the manifest automatically at build time. You do not need to write the intent filters by hand. This is a significant advantage of using Navigation's deep link support.
In your Activity:
class MainActivity : AppCompatActivity() {
private lateinit var navController: NavController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val navHostFragment = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
navController = navHostFragment.navController
// Navigation component handles deep link routing automatically
// when activity is launched with a matching intent
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
navController.handleDeepLink(intent)
}
}
The navController.handleDeepLink(intent) call in onNewIntent is important. When your app is already running and the user clicks a deep link (with android:launchMode="singleTop" or singleTask), the Activity receives onNewIntent rather than re-creating. Without this call, the navigation would not happen.
Accessing Arguments in Fragments
When the Navigation component routes a deep link to a destination, it parses the URL arguments and passes them as a navigation Bundle. Access them in your Fragment using the Safe Args generated class or the arguments Bundle directly.
With Safe Args (recommended):
class ProductDetailFragment : Fragment() {
private val args: ProductDetailFragmentArgs by navArgs()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val productId = args.productId
viewModel.loadProduct(productId)
}
}
Safe Args generates ProductDetailFragmentArgs from your nav graph arguments, giving you type-safe access. To enable Safe Args, add the plugin to your build.gradle:
// build.gradle (app)
plugins {
id("androidx.navigation.safeargs.kotlin")
}
// build.gradle (project)
buildscript {
dependencies {
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.7.7")
}
}
Without Safe Args, access arguments directly:
val productId = arguments?.getString("productId") ?: return
Nested Navigation Graphs
Real apps often have nested navigation graphs, for example a checkout flow that is a separate graph from the main navigation. Deep links work with nested graphs, but require care.
Define a nested graph:
<navigation
android:id="@+id/nav_graph"
app:startDestination="@id/homeFragment">
<fragment
android:id="@+id/homeFragment"
android:name="com.example.app.HomeFragment" />
<navigation
android:id="@+id/checkout_graph"
app:startDestination="@id/checkoutFragment">
<fragment
android:id="@+id/checkoutFragment"
android:name="com.example.app.CheckoutFragment">
<argument
android:name="cartToken"
app:argType="string" />
<deepLink
app:uri="https://yourdomain.com/checkout/{cartToken}" />
</fragment>
<fragment
android:id="@+id/paymentFragment"
android:name="com.example.app.PaymentFragment" />
<fragment
android:id="@+id/confirmationFragment"
android:name="com.example.app.ConfirmationFragment">
<argument
android:name="orderId"
app:argType="string" />
<deepLink
app:uri="https://yourdomain.com/orders/{orderId}" />
</fragment>
</navigation>
</navigation>
Deep links defined in nested graph destinations work correctly. The Navigation component builds the back stack from the nested graph's start destination, so pressing Back from a deep-linked destination within the checkout flow navigates to checkoutFragment rather than exiting the app.
Back Stack Construction
The back stack behavior for deep links differs from standard navigation. When a user arrives via a deep link, Navigation builds the back stack from the start destination of the graph to the linked destination. This means:
- User clicks
https://yourdomain.com/products/123 - Navigation routes to
productDetailFragment - Back stack:
homeFragment->productDetailFragment - User presses Back: arrives at
homeFragment - User presses Back again: exits app
This is the correct behavior: the user can always navigate back to a logical parent. Without this, pressing Back after a deep link would exit the app entirely.
To customize the back stack for a specific deep link, use the app:action attribute on the deep link element to reference an action with popUpTo configuration:
<deepLink
app:uri="https://yourdomain.com/products/{productId}"
app:action="@id/action_to_product_detail" />
<action
android:id="@+id/action_to_product_detail"
app:destination="@id/productDetailFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="false" />
Handling Deep Links Programmatically
Sometimes you need to navigate to a deep link destination programmatically, for example after processing a push notification payload or after an async validation step.
Use NavDeepLinkRequest:
val request = NavDeepLinkRequest.Builder
.fromUri("https://yourdomain.com/products/abc123".toUri())
.build()
navController.navigate(request)
This is equivalent to the user clicking the URL but triggers the navigation within your app without needing an actual Intent. The nav graph is consulted to match the URI to a destination, and arguments are extracted from the URI the same way.
You can also navigate directly to a destination using the generated directions class:
// Equivalent, bypasses URI matching, goes directly to destination
navController.navigate(
NavGraphDirections.actionToProductDetail("abc123")
)
The programmatic approach is useful when you receive a deep link URI from a notification or from your own deferred linking logic, and want to route the user after your app is already running.
Implicit vs Explicit Deep Links
The Navigation component distinguishes between two types:
Implicit deep links are URI patterns defined in the nav graph, handled by Android when the user clicks a link from outside the app (email, SMS, browser, etc.). These are what the <deepLink> element in the nav graph configures.
Explicit deep links are PendingIntent objects that navigate to a specific destination when triggered. These are typically used in notifications. They are constructed with NavDeepLinkBuilder:
val pendingIntent = NavDeepLinkBuilder(context)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.productDetailFragment)
.setArguments(bundleOf("productId" to "abc123"))
.createPendingIntent()
Explicit deep links build a complete back stack automatically, just like implicit deep links. When the user taps the notification and the PendingIntent fires, they land in the correct destination with the correct back stack. For a full treatment of notification deep links, see our article on deep linking from Android notifications.
Testing Navigation Deep Links
Navigation component provides a test artifact for instrumented tests:
// build.gradle
androidTestImplementation("androidx.navigation:navigation-testing:2.7.7")
Use TestNavHostController to verify navigation without running on a real device:
@Test
fun deepLinkNavigatesToProductDetail() {
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
val scenario = launchFragmentInContainer<HomeFragment>()
scenario.onFragment { fragment ->
navController.setGraph(R.navigation.nav_graph)
Navigation.setViewNavController(fragment.requireView(), navController)
}
// Simulate deep link navigation
val request = NavDeepLinkRequest.Builder
.fromUri("https://yourdomain.com/products/test123".toUri())
.build()
scenario.onFragment {
navController.navigate(request)
}
// Verify navigation happened
assertThat(navController.currentDestination?.id).isEqualTo(R.id.productDetailFragment)
}
For testing the full deep link entry from an external intent, use ActivityScenario:
@Test
fun externalDeepLinkOpensProductDetail() {
val intent = Intent(
Intent.ACTION_VIEW,
"https://yourdomain.com/products/test123".toUri()
).apply {
setPackage(ApplicationProvider.getApplicationContext<Context>().packageName)
}
val scenario = ActivityScenario.launch<MainActivity>(intent)
scenario.onActivity { activity ->
val navController = activity.findNavController(R.id.nav_host_fragment)
assertThat(navController.currentDestination?.id).isEqualTo(R.id.productDetailFragment)
}
}
URL Matching Behavior
Navigation's URI matching has some nuances worth knowing:
Query parameters are optional by default. If you define app:uri="https://yourdomain.com/search?q={query}", the deep link matches URLs both with and without the ?q= parameter. If the parameter is absent, the argument receives its default value.
Path parameters are required. https://yourdomain.com/products/{productId} will not match https://yourdomain.com/products/.
No wildcard matching. You cannot use * in URI templates. Each path segment must be a literal or a named parameter.
Scheme is part of matching. https://yourdomain.com/products/{id} matches only HTTPS URLs, not HTTP or custom scheme URLs. Define separate deep link elements for each scheme you want to handle.
For apps with complex URL structures, Navigation's matching may not cover every case. In those situations, combining Navigation's implicit deep link handling for simple cases with custom logic in onNewIntent for complex cases is a reasonable approach.
Combining with App Links Configuration
The <nav-graph> element in the manifest generates intent filters automatically, but it does not add android:autoVerify="true". You need to either add that manually to the generated intent filters or add your own explicit intent filter alongside the nav-graph element:
<activity android:name=".MainActivity">
<nav-graph android:value="@navigation/nav_graph" />
<!-- Explicit verified intent filter for App Links -->
<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" />
</intent-filter>
</activity>
The nav-graph element adds unverified intent filters that handle the routing. The explicit android:autoVerify="true" filter handles the verification. Both are needed for full App Link behavior.
For configuration details and the assetlinks.json setup, see the configuring Android guide.
Summary
Jetpack Navigation's deep link support reduces the boilerplate of deep link handling significantly. Defining URI patterns in the nav graph means the build system generates intent filters for you, argument extraction is handled automatically, and back stack construction follows your graph structure.
The key steps are: add <deepLink> elements to your nav graph destinations, use <nav-graph> in the manifest for automatic intent filter generation, add a separate android:autoVerify="true" filter for App Link verification, and call navController.handleDeepLink(intent) in onNewIntent.
For more patterns around the Kotlin code that handles deep link routing, see Kotlin deep link handling: modern Android patterns.
Get deep linking tips in your inbox
One email per week. No spam.