Navigation in Compose — Type-Safe Routes with Navigation 2.8+
Build type-safe navigation in Jetpack Compose using Kotlin Serialization and Navigation 2.8+ — no more string-based routes, argument parsing, or runtime crashes.
This is Part 3 of a 10-part series on Jetpack Compose.
- Your First Screen
- State Management
- Navigation (this post)
- Lists
- Theming
- Forms
- Side Effects
- Custom Layouts
- API Integration
- Migration from XML
The old way (string-based routes)
Before Navigation 2.8, routes were strings with arguments embedded:
// Define route
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
ProfileScreen(userId = userId!!)
}
// Navigate
navController.navigate("profile/user-123")
Problems: typos in route strings, arguments parsed from strings, no compile-time safety, nullable arguments need manual handling.
The new way (type-safe routes)
Navigation 2.8+ (released September 2024) uses Kotlin Serialization for type-safe routes. Define routes as data classes:
import kotlinx.serialization.Serializable
@Serializable
data object Home
@Serializable
data class Profile(val userId: String)
@Serializable
data class Settings(val section: String? = null)
@Serializable
data class ArticleDetail(val articleId: String, val title: String)
No strings. No argument parsing. Compile-time safety.
Setup
dependencies {
val navVersion = "2.8.5"
implementation("androidx.navigation:navigation-compose:$navVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
Make sure the serialization plugin is applied:
plugins {
kotlin("plugin.serialization")
}
Building the navigation graph
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
HomeScreen(
onProfileClick = { userId ->
navController.navigate(Profile(userId))
},
onSettingsClick = {
navController.navigate(Settings())
}
)
}
composable<Profile> { backStackEntry ->
val profile = backStackEntry.toRoute<Profile>()
ProfileScreen(
userId = profile.userId,
onBack = { navController.popBackStack() }
)
}
composable<Settings> { backStackEntry ->
val settings = backStackEntry.toRoute<Settings>()
SettingsScreen(
section = settings.section,
onBack = { navController.popBackStack() }
)
}
composable<ArticleDetail> { backStackEntry ->
val article = backStackEntry.toRoute<ArticleDetail>()
ArticleDetailScreen(
articleId = article.articleId,
title = article.title,
onBack = { navController.popBackStack() }
)
}
}
}
Key differences from the old API:
composable<Home>instead ofcomposable("home")navController.navigate(Profile("user-123"))instead ofnavController.navigate("profile/user-123")backStackEntry.toRoute<Profile>()instead of manual argument extraction
Passing arguments
Arguments are properties of the route data class:
@Serializable
data class ProductDetail(
val productId: String,
val categoryId: String,
val showReviews: Boolean = false // optional with default
)
// Navigate with arguments
navController.navigate(
ProductDetail(
productId = "prod-123",
categoryId = "cat-456",
showReviews = true
)
)
// Read arguments
composable<ProductDetail> { backStackEntry ->
val route = backStackEntry.toRoute<ProductDetail>()
ProductDetailScreen(
productId = route.productId,
categoryId = route.categoryId,
showReviews = route.showReviews
)
}
No navArgument blocks. No type declarations. No string parsing. The serialization handles everything.
Nullable arguments
@Serializable
data class Search(
val query: String? = null,
val category: String? = null
)
// Navigate without arguments
navController.navigate(Search())
// Navigate with some arguments
navController.navigate(Search(query = "kotlin", category = "tutorials"))
Nullable properties with defaults become optional URL parameters. No special handling needed.
Navigation with results
Pass data back to the previous screen:
@Composable
fun EditScreen(
navController: NavController,
initialValue: String
) {
var text by remember { mutableStateOf(initialValue) }
Column {
TextField(value = text, onValueChange = { text = it })
Button(onClick = {
navController.previousBackStackEntry
?.savedStateHandle
?.set("edited_value", text)
navController.popBackStack()
}) {
Text("Save")
}
}
}
@Composable
fun PreviousScreen(navController: NavController) {
val result = navController.currentBackStackEntry
?.savedStateHandle
?.getStateFlow("edited_value", "")
?.collectAsState()
Text("Result: ${result?.value}")
}
savedStateHandle survives configuration changes. Use it for simple results. For complex data, update a shared ViewModel instead.
Bottom navigation
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
@Serializable
data object HomeTab
@Serializable
data object ProfileTab
@Serializable
data object SettingsTab
data class BottomNavItem(
val label: String,
val icon: ImageVector,
val route: Any
)
val bottomNavItems = listOf(
BottomNavItem("Home", Icons.Default.Home, HomeTab),
BottomNavItem("Profile", Icons.Default.Person, ProfileTab),
BottomNavItem("Settings", Icons.Default.Settings, SettingsTab)
)
@Composable
fun MainScreen() {
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
val currentRoute = navController.currentBackStackEntryAsState()
.value?.destination?.route
bottomNavItems.forEach { item ->
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = currentRoute == item.route::class.qualifiedName,
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = HomeTab,
modifier = Modifier.padding(innerPadding)
) {
composable<HomeTab> { HomeScreen() }
composable<ProfileTab> { ProfileScreen() }
composable<SettingsTab> { SettingsScreen() }
}
}
}
The navigate options handle back stack properly:
popUpTo(startDestination)— prevents building up a back stack of tabssaveState = true— preserves the tab’s state when switching awayrestoreState = true— restores the tab’s state when switching backlaunchSingleTop = true— prevents creating duplicate destinations
Nested navigation
Group related screens into nested graphs:
@Serializable
data object AuthGraph
@Serializable
data object Login
@Serializable
data object Register
@Serializable
data object ForgotPassword
NavHost(navController = navController, startDestination = AuthGraph) {
navigation<AuthGraph>(startDestination = Login) {
composable<Login> {
LoginScreen(
onRegister = { navController.navigate(Register) },
onForgotPassword = { navController.navigate(ForgotPassword) },
onLoginSuccess = {
navController.navigate(Home) {
popUpTo(AuthGraph) { inclusive = true }
}
}
)
}
composable<Register> { RegisterScreen() }
composable<ForgotPassword> { ForgotPasswordScreen() }
}
composable<Home> { HomeScreen() }
}
After login, popUpTo(AuthGraph) { inclusive = true } removes the entire auth flow from the back stack. Pressing back from Home exits the app instead of going back to Login.
Deep links
composable<ArticleDetail>(
deepLinks = listOf(
navDeepLink {
uriPattern = "https://example.com/articles/{articleId}"
}
)
) { backStackEntry ->
val route = backStackEntry.toRoute<ArticleDetail>()
ArticleDetailScreen(articleId = route.articleId)
}
When the user opens https://example.com/articles/123, the app navigates directly to ArticleDetail(articleId = "123").
Testing navigation
@Test
fun homeScreen_navigatesToProfile() {
composeTestRule.setContent {
AppNavigation()
}
// Click profile button
composeTestRule
.onNodeWithText("View Profile")
.performClick()
// Verify navigation happened
composeTestRule
.onNodeWithText("Profile Screen")
.assertIsDisplayed()
}
For unit testing navigation logic without UI:
@Test
fun navigateToProfile_passesCorrectUserId() {
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
composeTestRule.setContent {
NavHost(navController = navController, startDestination = Home) {
composable<Home> { /* ... */ }
composable<Profile> { /* ... */ }
}
}
composeTestRule.runOnUiThread {
navController.navigate(Profile(userId = "user-123"))
}
val route = navController.currentBackStackEntry?.toRoute<Profile>()
assertEquals("user-123", route?.userId)
}
Summary
Type-safe navigation in Compose 2.8+:
- Define routes as
@Serializabledata classes/objects - Navigate with
navController.navigate(Route(args)) - Extract args with
backStackEntry.toRoute<Route>() - No strings, no argument parsing, compile-time safety
The compiler catches route mismatches, missing arguments, and type errors at build time instead of runtime. Combined with sealed routes and nested graphs, this covers everything from simple tab navigation to complex multi-flow apps.
Next: Lists in Compose — LazyColumn, LazyGrid & Performance.