Android Compose Navigation and Deep Linking Guide
Navigation is the skeleton of any mobile app. In Jetpack Compose, the Navigation component provides a declarative API for defining screens, passing arguments, handling deep links, and managing the back stack. Getting navigation right means your app feels intuitive. Getting it wrong means users get lost, state gets corrupted, and your codebase becomes a maze.
This guide covers everything you need to build production-quality navigation in Compose apps, from basic routing to nested graphs and deep linking.
Setting Up Navigation
Add the Navigation Compose dependency:
dependencies {
implementation "androidx.navigation:navigation-compose:2.6.0"
}
Basic Navigation Structure
Define your app’s navigation in a central composable:
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(
onProductClick = { productId ->
navController.navigate("product/$productId")
},
onCartClick = {
navController.navigate("cart")
},
onProfileClick = {
navController.navigate("profile")
}
)
}
composable(
route = "product/{productId}",
arguments = listOf(
navArgument("productId") { type = NavType.StringType }
)
) { backStackEntry ->
val productId = backStackEntry.arguments?.getString("productId") ?: return@composable
ProductDetailScreen(
productId = productId,
onBack = { navController.popBackStack() },
onAddToCart = { navController.navigate("cart") }
)
}
composable("cart") {
CartScreen(
onBack = { navController.popBackStack() },
onCheckout = { navController.navigate("checkout") }
)
}
composable("profile") {
ProfileScreen(
onBack = { navController.popBackStack() }
)
}
}
}
Type-Safe Route Definitions
String-based routes are error-prone. Define routes as sealed classes or objects for type safety:
sealed class Screen(val route: String) {
object Home : Screen("home")
object Cart : Screen("cart")
object Profile : Screen("profile")
object Checkout : Screen("checkout")
data class ProductDetail(val productId: String = "{productId}") :
Screen("product/{productId}") {
fun createRoute(productId: String) = "product/$productId"
}
data class CategoryList(val categoryId: String = "{categoryId}") :
Screen("category/{categoryId}") {
fun createRoute(categoryId: String) = "category/$categoryId"
}
data class Search(
val query: String = "",
val filter: String = ""
) : Screen("search?query={query}&filter={filter}") {
fun createRoute(query: String, filter: String = "") =
"search?query=$query&filter=$filter"
}
}
Usage becomes cleaner and compile-time checked:
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
HomeScreen(
onProductClick = { productId ->
navController.navigate(
Screen.ProductDetail().createRoute(productId)
)
}
)
}
composable(
route = Screen.ProductDetail().route,
arguments = listOf(
navArgument("productId") { type = NavType.StringType }
)
) { backStackEntry ->
val productId = backStackEntry.arguments?.getString("productId")!!
ProductDetailScreen(productId = productId)
}
}
Passing Complex Argum
ents
Simple string and integer arguments work with URL-style routes. For complex objects, use several strategies:
Approach 1: Pass IDs and Load Data
The recommended approach — pass identifiers and let the destination screen load its data:
composable(
route = "order/{orderId}",
arguments = listOf(
navArgument("orderId") { type = NavType.StringType }
)
) { backStackEntry ->
val orderId = backStackEntry.arguments?.getString("orderId")!!
// ViewModel loads order data based on ID
OrderDetailScreen(orderId = orderId)
}
Approach 2: JSON Serialisation for Navigation Arguments
When you need to pass small data objects:
fun NavController.navigateWithObject(route: String, key: String, data: Any) {
val json = Gson().toJson(data)
val encodedJson = URLEncoder.encode(json, "UTF-8")
navigate("$route?$key=$encodedJson")
}
// Retrieve
inline fun <reified T> NavBackStackEntry.getObject(key: String): T? {
val json = arguments?.getString(key) ?: return null
val decodedJson = URLDecoder.decode(json, "UTF-8")
return Gson().fromJson(decodedJson, T::class.java)
}
Approach 3: Shared ViewModel
For data shared between related screens in a navigation graph:
@Composable
fun CheckoutGraph(navController: NavHostController) {
val checkoutViewModel: CheckoutViewModel = hiltViewModel()
NavHost(
navController = navController,
startDestination = "shipping"
) {
composable("shipping") {
ShippingScreen(
viewModel = checkoutViewModel,
onNext = { navController.navigate("payment") }
)
}
composable("payment") {
PaymentScreen(
viewModel = checkoutViewModel,
onNext = { navController.navigate("review") }
)
}
composable("review") {
ReviewScreen(
viewModel = checkoutViewModel,
onConfirm = { navController.navigate("confirmation") }
)
}
}
}
Nested Navigation
Graphs
Large apps benefit from organising navigation into feature-specific graphs:
fun NavGraphBuilder.authGraph(navController: NavHostController) {
navigation(
startDestination = "login",
route = "auth"
) {
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("main") {
popUpTo("auth") { inclusive = true }
}
},
onSignUp = { navController.navigate("register") },
onForgotPassword = { navController.navigate("forgot_password") }
)
}
composable("register") {
RegisterScreen(
onRegistered = {
navController.navigate("main") {
popUpTo("auth") { inclusive = true }
}
}
)
}
composable("forgot_password") {
ForgotPasswordScreen(
onBack = { navController.popBackStack() }
)
}
}
}
fun NavGraphBuilder.mainGraph(navController: NavHostController) {
navigation(
startDestination = "home",
route = "main"
) {
composable("home") { HomeScreen(navController) }
composable("search") { SearchScreen(navController) }
composable("profile") { ProfileScreen(navController) }
}
}
// Root navigation
@Composable
fun RootNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "auth") {
authGraph(navController)
mainGraph(navController)
}
}
Bottom Navigation Integration
Combining bottom navigation with Compose Navigation:
@Composable
fun MainScreen() {
val navController = rememberNavController()
val currentRoute = navController.currentBackStackEntryAsState()
.value?.destination?.route
Scaffold(
bottomBar = {
NavigationBar {
BottomNavItem.entries.forEach { item ->
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = currentRoute == item.route,
onClick = {
navController.navigate(item.route) {
// Pop up to start destination to avoid
// building up a large back stack
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = BottomNavItem.HOME.route,
modifier = Modifier.padding(paddingValues)
) {
composable(BottomNavItem.HOME.route) {
HomeScreen(navController)
}
composable(BottomNavItem.SEARCH.route) {
SearchScreen(navController)
}
composable(BottomNavItem.ORDERS.route) {
OrdersScreen(navController)
}
composable(BottomNavItem.PROFILE.route) {
ProfileScreen(navController)
}
}
}
}
enum class BottomNavItem(
val route: String,
val label: String,
val icon: ImageVector
) {
HOME("home", "Home", Icons.Default.Home),
SEARCH("search", "Search", Icons.Default.Search),
ORDERS("orders", "Orders", Icons.Default.List),
PROFILE("profile", "Profile", Icons.Default.Person)
}
Deep Linking
Deep links allow external sources (web links, notifications, other apps) to navigate directly to specific screens:
Declaring Deep Links
composable(
route = "product/{productId}",
arguments = listOf(
navArgument("productId") { type = NavType.StringType }
),
deepLinks = listOf(
navDeepLink {
uriPattern = "https://yourapp.com.au/product/{productId}"
},
navDeepLink {
uriPattern = "yourapp://product/{productId}"
}
)
) { backStackEntry ->
val productId = backStackEntry.arguments?.getString("productId")!!
ProductDetailScreen(productId = productId)
}
Android Manifest Configuration
<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="yourapp.com.au"
android:pathPrefix="/product" />
</intent-filter>
<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="yourapp" />
</intent-filter>
</activity>
Handling Deep Links from Notifications
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
// Handle deep link from notification
LaunchedEffect(Unit) {
val deepLinkUri = intent?.data
?: intent?.getStringExtra("deep_link")?.let { Uri.parse(it) }
deepLinkUri?.let { uri ->
navController.navigate(uri)
}
}
AppNavigation(navController = navController)
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// Handle deep links when activity is already running
intent?.data?.let { uri ->
navController.navigate(uri)
}
}
}
App Links Verification
For HTTPS deep links, verify your domain with a Digital Asset Links file:
// https://yourapp.com.au/.well-known/assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.au.yourcompany.app",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:..."
]
}
}]
Navigation Best Practices
-
Keep NavController at the top level. Pass callback lambdas to screens rather than passing the NavController directly. This keeps screens testable and reusable.
-
Use
popUpTostrategically. Prevent users from navigating back to screens they should not revisit (like login after authentication). -
Handle configuration changes. Navigation state survives configuration changes by default with
rememberNavController, but ensure your ViewModels useSavedStateHandlefor critical state. -
Test deep links thoroughly. Use
adb shell am start -d "yourapp://product/123"to test deep link handling. -
Avoid circular navigation. Screen A navigating to B, which navigates back to A, creates a confusing back stack. Use
launchSingleTop = trueto prevent duplicates.
Navigation is infrastructure — users never notice good navigation, but they immediately feel bad navigation. Invest the time to get it right, and every feature you build on top benefits.
Building an Android app with Jetpack Compose? Our team at eawesome delivers modern Android experiences for Australian businesses.