The Cost of String-Based Routes
"product/{productId}" in one file, "product/$id" in another, and a getString("productId") in a third have to agree, and nothing checks that they do until a user taps the button. Rename an argument and the build stays green.
An earlier version of this article put numbers on that. It cited a 2025 analysis of 1,200 Play Store apps and attributed 14% of crashes to navigation. We cannot point to any such source, so the figure has been removed. The argument does not need it. The failure mode is structural, and it is the one the Jetpack team built an API to remove.
Type-safe navigation arrived in Navigation 2.8.0, released on 4 September 2024. The release notes describe it: "Navigation now provides type-safety for the Kotlin DSL (used by Navigation Compose) using Kotlin Serialization to allow you to define destinations in your navigation graph via type safe objects and data classes" (release notes). The guide calls the APIs "equivalent to what Safe Args provides to navigation graphs built using XML."Should a new app use Navigation Compose or Navigation 3?
androidx.navigation, is at 2.10.2 (23 September 2026), and its release page opens with a caution: "This library is in maintenance mode and will only receive critical fixes; new features are not planned." It is supported. It is not where new work is going.
The successor is Navigation 3, stable since 1.0.0 on 19 November 2025 and at 1.2.0 as of 7 October 2026 (release notes). Its model is different: "With Navigation 3, you have full control over your back stack, and you navigate between destinations by adding and removing items from a list." There is no NavController and no graph. Keys go on a list you own, and a NavDisplay renders whatever is on top.
| Navigation Compose 2.x | Navigation 3 | |
|---|---|---|
| Status | Maintenance mode, critical fixes only | Stable since November 2025, 1.3.0 in alpha |
| Back stack | Owned by NavController | A list you own |
| Destinations | composable<T> in a NavHost graph | entry<T> in an entry provider |
| Type safety | @Serializable routes, from 2.8.0 | Keys are your own types from the start |
// Navigation 3: keys, a back stack you own, and a NavDisplay
data object ProductList
data class ProductDetail(val id: String)
@Composable
fun App() {
val backStack = remember { mutableStateListOf<Any>(ProductList) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<ProductList> {
ProductListScreen(
// Navigating forward is adding a key
onProductClick = { id -> backStack.add(ProductDetail(id)) }
)
}
entry<ProductDetail> { key ->
ProductDetailScreen(productId = key.id)
}
}
)
}Defining Routes as Kotlin Types
@Serializable type. The guide gives the rules for choosing one:
- "Object: Use an object for routes without arguments."
- "Class: Use a class or data class for routes with arguments."
and one condition that applies to both: "In all cases the object or class must be serializable." That means the Kotlin Serialization compiler plugin has to be applied to the module, not just the runtime library added.
Arguments with default values are optional arguments. A nested graph is an object used as the graph's own route.import kotlinx.serialization.Serializable
// No arguments: an object
@Serializable
object Home
// Required argument: a data class
@Serializable
data class ProductDetail(val productId: String)
// Optional arguments: default values
@Serializable
data class Search(
val query: String = "",
val category: String? = null,
)
// Nested graph and its destinations
@Serializable
object AuthGraph
@Serializable
object Login
@Serializable
data class ResetPassword(val email: String)Building the NavHost with the Type-Safe DSL
composable<T>. The guide spells out what that buys: "Defining the destination type is a more robust approach than passing a route string", and "The route class defines the type of each navigation argument", "so there's no need for NavArgument."
Reading arguments is one call. toRoute() "recreates the Profile object from the NavBackStackEntry and its arguments", in the guide's example. A ViewModel gets the same object from its SavedStateHandle with toRoute<T>(), which keeps argument parsing out of the UI and fits either side of the MVVM or MVI choice.
Navigating is passing an instance. The compiler will not let you build a ProductDetail without a productId, and that is the whole point: "Because the parameters of the data class are typed, when you pass an instance of that class to navigate(), the arguments are necessarily type safe."@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = Home
) {
composable<Home> {
HomeScreen(
onProductClick = { productId ->
navController.navigate(ProductDetail(productId))
},
onSearchClick = {
navController.navigate(Search())
}
)
}
composable<ProductDetail> { backStackEntry ->
val route: ProductDetail = backStackEntry.toRoute()
ProductDetailScreen(
productId = route.productId,
onBack = { navController.popBackStack() }
)
}
composable<Search> { backStackEntry ->
val route: Search = backStackEntry.toRoute()
SearchScreen(
initialQuery = route.query,
initialCategory = route.category
)
}
// Nested graph
navigation<AuthGraph>(startDestination = Login) {
composable<Login> {
LoginScreen(
onForgotPassword = { email ->
navController.navigate(ResetPassword(email))
}
)
}
composable<ResetPassword> { entry ->
val route: ResetPassword = entry.toRoute()
ResetPasswordScreen(email = route.email)
}
}
}
}
// The same route, read in a ViewModel
class ProductDetailViewModel(
savedStateHandle: SavedStateHandle,
repository: ProductRepository,
) : ViewModel() {
private val route = savedStateHandle.toRoute<ProductDetail>()
val product = repository.observeProduct(route.productId)
}Deep Links with Typed Routes
navDeepLink<Profile>(basePath = "$uri/profile"), passed in the destination's deepLinks list. The arguments are taken from the route type, so the URL pattern and the route cannot drift apart.
The earlier version of this section made two claims we could not find in the documentation: that a malformed link is "rejected gracefully", and that enum arguments in a query string deserialize automatically. Both are removed. Treat an incoming link as untrusted input: validate the argument values in the ViewModel that receives them, and write a test that opens the app with a bad one.@Serializable
data class ProductDetail(val productId: String)
const val BASE_URI = "https://example.com"
composable<ProductDetail>(
deepLinks = listOf(
navDeepLink<ProductDetail>(basePath = "$BASE_URI/product")
)
) { backStackEntry ->
val route: ProductDetail = backStackEntry.toRoute()
ProductDetailScreen(productId = route.productId)
}Testing Navigation
ProductDetail("SKU-42"), compared with equals, with no string parsing.
For tests that need a real controller, the Navigation Testing artifact provides TestNavHostController, "that provides APIs for setting the current destination and verifying the back stack after NavController.navigate() operations" (testing guide).
The fake below is our own pattern, not a library API. Keep navigation out of composables that do not need it: a screen that receives onProductClick: (String) -> Unit can be tested with the techniques in the Compose testing guide and never see a controller.// Our own test double, not a library class
class FakeNavigator : Navigator {
val routes = mutableListOf<Any>()
override fun navigate(route: Any) { routes += route }
}
@Test
fun `tapping a product navigates to its detail`() {
val navigator = FakeNavigator()
val viewModel = HomeViewModel(navigator, fakeRepo)
viewModel.onProductClick("SKU-42")
assertEquals(
ProductDetail(productId = "SKU-42"),
navigator.routes.last()
)
}Migration Strategy from String Routes
composable<T> declaration and every navigate call for that destination together. A destination that is half converted is worse than one that is not converted.
Then decide about Navigation 3 as a separate question, with the comparison above in hand.Key Takeaways
- 1Navigation Compose 2.x is in maintenance mode. Navigation 3, stable since November 2025, is the library for new apps.
- 2Type-safe routes arrived in Navigation 2.8.0: @Serializable objects and data classes instead of route strings.
- 3Declare destinations with composable<T> and read arguments with toRoute(), in the composable or from SavedStateHandle.
- 4The Kotlin Serialization compiler plugin must be applied to every module that declares a route.
- 5Declare deep links against the route type with navDeepLink<T>(basePath), and still validate what arrives.
- 6Migrate one destination per pull request, argument-heavy screens before simple ones.
Frequently Asked
Is type-safe navigation worth the migration?
For any app with more than a handful of destinations, yes. A route string, its argument keys and the code that reads them must agree, and the compiler checks none of it. A @Serializable route makes all three one type, so a renamed or missing argument becomes a compile error instead of a crash.
Should I use Navigation Compose or Navigation 3?
Start new apps on Navigation 3. The androidx.navigation release page states that the library is in maintenance mode and will only receive critical fixes. An existing app on Navigation Compose can stay there and move to type-safe routes now; those route types carry over as Navigation 3 keys when you do migrate.
How do I read a type-safe route argument in a ViewModel?
Call toRoute on the SavedStateHandle the ViewModel receives, with the route class as the type argument. It returns the same typed object the destination was navigated to with, so the ViewModel reads productId as a property. The composable then needs no argument handling at all and only renders state.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.