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

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)

Pro Tips

📋 Quick Reference — Navigation

ConceptCompose API
Controllerval nav = rememberNavController()
GraphNavHost(nav, startDestination = "home")
Destinationcomposable("home") { HomeScreen() }
Navigatenav.navigate("details")
Read argentry.arguments?.getString("id")

🎉 Lesson Complete!

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

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.