Let me be honest with you.
The first time I tried to navigate between two screens in Jetpack Compose, I copy-pasted code from Claude, it worked, and I moved on. I had zero idea what NavHost, NavController, or BackStack actually meant.
Then my app had 5 screens. Then 8. Then I needed to pass data between them. Then everything broke, and I finally had to sit down and actually learn this.
This article is what I wish someone had explained to me back then.
We're building a simple e-commerce app — Product List → Product Detail → Cart — and we're going to understand every single line of navigation code we write. No copy-pasting in the dark this time.
First, What Does "Navigation" Even Mean?
Before we write a single line of code, let's make sure we actually understand what's happening.
Imagine your phone screen is a stack of physical cards on a table:
- You open the app → the first card (Product List) is placed on the table.
- You tap a product → a new card (Product Detail) is placed on top of the first one.
- You press back → that top card is removed, and you see the Product List again.
That pile of cards? That's your Back Stack.
Android manages this stack automatically. Your job is just to tell it: "add this screen" or "remove the top one." That's navigation.
Simple. Let's build it.
🔧 Setup
Add this dependency to your build.gradle.kts (app level):
dependencies {
implementation("androidx.navigation:navigation-compose:2.9.0")
}
Sync and you're ready.
Meet the 3 Players
Compose Navigation has three key pieces. Think of them like a team with very specific roles:
| Who | What They Do |
|---|---|
NavController |
The pilot — decides where you go |
NavHost |
The airport — the container that holds all destinations |
composable() |
The gate — registers each screen as a destination |
Let's see them in action.
rememberNavController()
This creates your NavController and keeps it alive across recompositions:
val navController = rememberNavController()
Think of this as creating the remote control for your whole app's navigation.
NavHost + composable()
This is where you declare every screen your app has:
NavHost(
navController = navController,
startDestination = "product_list"
) {
composable("product_list") { ProductListScreen() }
composable("product_detail") { ProductDetailScreen() }
composable("cart") { CartScreen() }
}
startDestination is just the screen that shows up first when the app launches. Everything else gets added when the user navigates there.
That's the whole system. Now let's make it production-quality.
Stop Hardcoding Strings — Use a Sealed Class
Right now, our routes are raw strings like "product_list".
Here's the problem: you'll use that string in multiple places — the NavHost, the navigate call, the popBackStack call... One typo anywhere? App crashes at runtime with zero help from the compiler.
The fix is a sealed class that acts as a single source of truth for all your routes:
sealed class Screen(val route: String) {
object ProductList : Screen("product_list")
object Cart : Screen("cart")
// Screens with arguments get their own createRoute() function
object ProductDetail : Screen("product_detail/{productId}") {
fun createRoute(productId: Int) = "product_detail/$productId"
}
}
Now if you ever rename a screen, you change it in one place and every reference updates automatically. The compiler is your safety net.
Setting Up the Full NavHost
With the sealed class in place, your NavHost becomes clean and readable:
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.ProductList.route
) {
composable(Screen.ProductList.route) {
ProductListScreen(navController)
}
composable(
route = Screen.ProductDetail.route,
arguments = listOf(
navArgument("productId") { type = NavType.IntType }
)
) { backStackEntry ->
val productId = backStackEntry.arguments?.getInt("productId") ?: 0
ProductDetailScreen(productId = productId, navController = navController)
}
composable(Screen.Cart.route) {
CartScreen(navController)
}
}
}
Notice how ProductDetail declares an arguments list — that's how you tell the NavHost "hey, this screen expects some data to arrive with it."
Navigating to a Screen
Now let's make the user actually go somewhere. When a product is tapped in the list, we navigate to its detail screen:
@Composable
fun ProductListScreen(navController: NavController) {
val products = listOf(
Product(id = 1, name = "Air Max 90", price = 120.0),
Product(id = 2, name = "Jordan 1 Retro", price = 180.0),
Product(id = 3, name = "New Balance 550", price = 95.0)
)
LazyColumn {
items(products) { product ->
ProductItem(
product = product,
onClick = {
// 👇 This is all navigation is — "go here"
navController.navigate(Screen.ProductDetail.createRoute(product.id))
}
)
}
}
}
createRoute(product.id) produces "product_detail/1" — it fills in the argument in the route template. Clean, no string manipulation in your UI code.
Passing Data: The Simple Way (ID)
The golden rule of navigation: pass the minimum data needed.
Usually that's just an ID. The receiving screen fetches the full data using that ID (from a ViewModel, a repository, wherever). This is the officially recommended approach.
@Composable
fun ProductDetailScreen(
productId: Int,
navController: NavController,
viewModel: ProductViewModel = hiltViewModel()
) {
// Fetch the full product using the ID
val product by viewModel.getProduct(productId).collectAsState()
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = product?.name ?: "Loading...",
style = MaterialTheme.typography.headlineMedium
)
Text(text = "$${product?.price}")
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { navController.navigate(Screen.Cart.route) }
) {
Text("Add to Cart 🛒")
}
}
}
ID goes in → ViewModel fetches the product → UI shows it. Simple and predictable.
Going Back
Three flavors of going back, depending on what you need:
// 1. Go back one screen — the most common case
navController.popBackStack()
// 2. Go back to a specific screen (keeps it in the stack)
navController.popBackStack(
route = Screen.ProductList.route,
inclusive = false // false = keep ProductList on the stack, land on it
)
// 3. Go back and clear everything including the destination
navController.popBackStack(
route = Screen.ProductList.route,
inclusive = true // true = remove ProductList too (useful for login → home flows)
)
The inclusive flag is the one that trips everyone up. A simple way to remember it:
-
inclusive = false→ "go back TO this screen" -
inclusive = true→ "go back PAST this screen"
🧩 The Complete Picture
Let's look at everything together. Here's your final AppNavigation.kt:
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.ProductList.route
) {
composable(Screen.ProductList.route) {
ProductListScreen(navController)
}
composable(
route = Screen.ProductDetail.route,
arguments = listOf(
navArgument("productId") { type = NavType.IntType }
)
) { backStackEntry ->
val productId = backStackEntry.arguments?.getInt("productId") ?: 0
ProductDetailScreen(
productId = productId,
navController = navController
)
}
composable(Screen.Cart.route) {
CartScreen(navController)
}
}
}
And your sealed class:
sealed class Screen(val route: String) {
object ProductList : Screen("product_list")
object Cart : Screen("cart")
object ProductDetail : Screen("product_detail/{productId}") {
fun createRoute(productId: Int) = "product_detail/$productId"
}
}
That's a complete, production-ready navigation setup. No magic. No mystery.
The next time someone mentions the Back Stack, NavHost, or sealed class routes — you'll know exactly what they mean and why it's set up that way.
What are you building with Compose Navigation? Drop it in the comments 👇
And if this saved you from a Claude rabbit hole, share it with the Android dev who needs it right now. 🙌
Top comments (0)