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:

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>
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)
        }
    }
}

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:..."
    ]
  }
}]
  1. Keep NavController at the top level. Pass callback lambdas to screens rather than passing the NavController directly. This keeps screens testable and reusable.

  2. Use popUpTo strategically. Prevent users from navigating back to screens they should not revisit (like login after authentication).

  3. Handle configuration changes. Navigation state survives configuration changes by default with rememberNavController, but ensure your ViewModels use SavedStateHandle for critical state.

  4. Test deep links thoroughly. Use adb shell am start -d "yourapp://product/123" to test deep link handling.

  5. Avoid circular navigation. Screen A navigating to B, which navigates back to A, creates a confusing back stack. Use launchSingleTop = true to 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.