Navigation in Compose
Reviewed & published by Brayan K
Most apps have more than one screen. Navigation-Compose lets you define screens as routes, move between them with a NavController, and get a managed back stack for free.
Part of the free Kotlin course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
By the end you'll set up a NavHost, declare composable routes, navigate between them, and pass arguments along the route.
What You'll Learn in This Lesson
- Creating a NavController
- Defining a graph with NavHost
- Declaring composable routes
- Navigating with navigate()
- Setting the startDestination
- Passing and reading route arguments
1️⃣ NavController, NavHost, and Routes
Create a controller with rememberNavController(), then declare your graph in a NavHost. Each composable(route) maps a route string to a screen.
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@Composable
fun AppNav() {
// The NavController manages the back stack.
val navController = rememberNavController()
// NavHost defines the graph: which route shows which screen.
NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(onOpenDetails = { navController.navigate("details") })
}
composable("details") {
DetailsScreen()
}
}
}
@Composable
fun HomeScreen(onOpenDetails: () -> Unit) {
Button(onClick = onOpenDetails) { Text("Open details") }
}
@Composable
fun DetailsScreen() {
Text("Details screen")
}navController.navigate("details") pushes the Details destination; the system Back button pops it.
2️⃣ Passing Arguments
Declare a placeholder with curly braces in the route — profile/{userId} — list it in arguments, then read it from the destination's backStackEntry.arguments.
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
@Composable
fun AppNavWithArgs() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "list") {
composable("list") {
// Interpolate the value into the route string.
ListScreen(onPick = { id -> navController.navigate("profile/$id") })
}
// {userId} is a path argument placeholder.
composable(
route = "profile/{userId}",
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
Text("Profile of $userId")
}
}
}
@Composable
fun ListScreen(onPick: (String) -> Unit) {
Text("(tap a row to open profile/42)")
}Your turn. Replace the TODO, then run and compare.
// 🎯 YOUR TURN — replace the TODO then run.
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@Composable
fun TwoScreenApp() {
val navController = rememberNavController()
// 1) Start the app on the "home" route.
// 👉 replace TODO with: startDestination = "home"
NavHost(navController = navController, TODO) {
composable("home") { HomeScreen() }
composable("about") { AboutScreen() }
}
}Mini-Challenge: A Settings Route
Add a second destination and navigate to it from a button, then return with Back.
// 🎯 MINI-CHALLENGE: a settings route.
//
// 1. Inside a NavHost with startDestination = "main",
// add a composable("main") { } that shows a Button.
// 2. The button's onClick calls navController.navigate("settings").
// 3. Add a composable("settings") { } showing Text("Settings").
//
// ✅ Tapping the button navigates to the Settings screen;
// system Back returns to main.
@Composable
fun SettingsApp() {
// your code here
}Common Errors (and the fix)
- Route typos — The route in navigate("...") must match a composable("...") exactly, or you'll crash at runtime.
- Forgetting the argument placeholder — To pass userId, the route must include {userId} and declare it in arguments.
- New controller per screen — Create one rememberNavController() at the top and pass it down; don't make a fresh one each screen.
- Missing startDestination — NavHost requires a startDestination route.
Pro Tips
- 💡 Centralize routes: keep route strings in constants or a sealed class to avoid typos.
- 💡 Pass ids, not objects: navigate with an id and load the data on the destination.
- 💡 Hoist navigation: pass lambdas like onOpenDetails so screens stay decoupled from the controller.
📋 Quick Reference — Navigation
| Concept | Compose API |
|---|---|
| Controller | val nav = rememberNavController() |
| Graph | NavHost(nav, startDestination = "home") |
| Destination | composable("home") { HomeScreen() } |
| Navigate | nav.navigate("details") |
| Read arg | entry.arguments?.getString("id") |
🎉 Lesson Complete!
- ✅ rememberNavController() creates the controller
- ✅ NavHost declares the route graph
- ✅ composable(route) maps routes to screens
- ✅ navigate() moves and manages the back stack
- ✅ Curly-brace placeholders pass arguments along routes
Practice quiz
Which object drives navigation between Compose destinations?
- NavManager
- NavController
- Router
- ScreenStack
Answer: NavController. The NavController manages the back stack and performs navigate() calls.
Which composable defines the navigation graph?
- NavGraph
- NavHost
- Router
- ScreenHost
Answer: NavHost. NavHost hosts the graph, mapping routes to composable destinations.
How do you create a NavController in Compose?
- NavController()
- remember { NavController() }
- rememberNavController()
- getNavController()
Answer: rememberNavController(). rememberNavController() creates and remembers a NavHostController for Compose.
Inside a NavHost, how do you declare a destination?
- composable("home") { HomeScreen() }
- screen("home")
- route("home")
- destination("home")
Answer: composable("home") { HomeScreen() }. The composable(route) { } builder maps a route string to a screen composable.
What does navController.navigate("details") do?
- Closes the app
- Navigates to the 'details' destination, pushing it on the back stack
- Deletes the route
- Reloads the current screen
Answer: Navigates to the 'details' destination, pushing it on the back stack. navigate(route) moves to that destination and adds it to the back stack.
How is a route argument declared in a route string?
- details:{id}
- details/<id>
- details/{id}
- details?id
Answer: details/{id}. Curly braces mark a path argument placeholder, e.g. details/{id}.
How do you read a String argument inside the destination?
- backStackEntry.arguments?.getString("id")
- args.id
- navController.get("id")
- intent.getStringExtra("id")
Answer: backStackEntry.arguments?.getString("id"). The NavBackStackEntry's arguments Bundle holds the parsed route arguments.
What parameter sets the first screen shown by a NavHost?
- firstRoute
- home
- startDestination
- initial
Answer: startDestination. NavHost(navController, startDestination = "home") sets the initial destination.
To pass an argument when navigating, you typically build the route how?
- navigate("details", id)
- navigate("details/\$id")
- navigate("details").with(id)
- navigate { id }
Answer: navigate("details/\$id"). You interpolate the value into the route string, e.g. navigate("details/42").
Why use string routes and a NavHost instead of manually swapping composables?
- It is required by Kotlin
- It removes the need for state
- It is the only way to show text
- It gives a managed back stack, deep links, and argument handling
Answer: It gives a managed back stack, deep links, and argument handling. Navigation-Compose provides a real back stack, type-safe-ish routing, deep links, and arguments.
Continue this course
- Previous: Compose Layouts, Modifiers & Lists
- Next: Android Fundamentals: Activities & Lifecycle — Activities, the lifecycle, ViewModel and viewModelScope
- Quick reference: Kotlin cheat sheet
Frequently asked questions
What's the difference between NavController and NavHost?
The NavController is the brain: it owns the back stack and performs navigate() and popBackStack() calls. The NavHost is the container: it's a composable that hosts the navigation graph and swaps in the right destination composable for the current route. You create the controller with rememberNavController() and hand it to a NavHost, which declares the routes.
How do routes and destinations relate?
A route is just a String identifier like "home" or "profile/{userId}". Inside the NavHost lambda you map each route to a screen with composable("home") { HomeScreen() }. When you call navController.navigate("home"), the NavHost looks up that route and shows the matching composable. The startDestination parameter says which route appears first.
How do I pass arguments between screens?
Declare a placeholder in the route with curly braces, e.g. "profile/{userId}", and list it in arguments with a NavType. When navigating, interpolate the value into the route string: navController.navigate("profile/42"). In the destination, read it from backStackEntry.arguments, for example backStackEntry.arguments?.getString("userId"). For larger objects, pass an id and fetch the data from a repository.
Why not just swap composables with my own state?
You can for trivial cases, but Navigation-Compose gives you a real, managed back stack (so the system Back button works correctly), deep-link support, argument parsing, and consistent transitions. Rolling your own quickly becomes error-prone once you add nested graphs, arguments, and back-stack edge cases. The library handles those for you.