PCSalt
YouTube GitHub
Back to Jetpack Compose
Jetpack Compose · 2 min read

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.

  1. Your First Screen
  2. State Management
  3. Navigation (this post)
  4. Lists
  5. Theming
  6. Forms
  7. Side Effects
  8. Custom Layouts
  9. API Integration
  10. 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 of composable("home")
  • navController.navigate(Profile("user-123")) instead of navController.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.

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 tabs
  • saveState = true — preserves the tab’s state when switching away
  • restoreState = true — restores the tab’s state when switching back
  • launchSingleTop = 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.

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 @Serializable data 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.