SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:44
UP00:01
MOD · ARTICLE · JETPACK COMPOSES/N · AX-COMPOSPUBLISHED
All Articles
Jetpack Compose · Updated 12 MIN

Type-Safe Navigation in Jetpack Compose: From Strings to Kotlin Serialization

Eliminate runtime route crashes by replacing string-based navigation with Kotlin Serialization-powered type-safe routes, nested graphs, and deep link handling.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

The Cost of String-Based Routes

A route written as a string is a contract the compiler cannot read. "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?

This question did not exist when the article was first written, and it now comes before everything else. The library this article teaches, 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.xNavigation 3
StatusMaintenance mode, critical fixes onlyStable since November 2025, 1.3.0 in alpha
Back stackOwned by NavControllerA list you own
Destinationscomposable<T> in a NavHost graphentry<T> in an entry provider
Type safety@Serializable routes, from 2.8.0Keys are your own types from the start
The practical reading: an existing app on Navigation Compose should move to type-safe routes, which is what the rest of this article covers, because that is a contained change with an immediate payoff. A new app should start on Navigation 3. A migration between the two is a separate project; Google's migration guide assumes it is done "in a single, atomic change" (guide). The first step is not wasted. Navigation 3 keys "can be any type but are usually simple, serializable data classes" (basics), which is what a type-safe route already is.
kotlin
// 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

In Navigation Compose a type-safe route is a @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.
kotlin
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

Each destination is declared with 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."
kotlin
@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

A deep link is input from outside the app, which makes it the place where a hand-built route string is most exposed. With typed routes you declare the link against the route class and give it a base path. The deep link guide shows the form: 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.
kotlin
@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

Typed routes make navigation assertable as data. If a screen takes callbacks and a ViewModel exposes events, a test can assert that tapping a product produced 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.
kotlin
// 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

Move one destination at a time, in this order: 1. Set up the build. Navigation 2.8.0 or later and the Kotlin Serialization plugin in every module that declares a route. Put both in the version catalog so modules cannot disagree. 2. New screens first. Every new destination is a typed route from day one. 3. Argument-heavy screens next. A destination with three arguments has three places to mistype a key. That is where the change pays most. 4. Then the screens your crash reporter names. Use your own data here, since it is the only navigation crash data that describes your app. 5. Simple screens last. Keep each conversion in its own pull request: the route type, the 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.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Navigation Compose 2.x is in maintenance mode. Navigation 3, stable since November 2025, is the library for new apps.
  2. 2Type-safe routes arrived in Navigation 2.8.0: @Serializable objects and data classes instead of route strings.
  3. 3Declare destinations with composable<T> and read arguments with toRoute(), in the composable or from SavedStateHandle.
  4. 4The Kotlin Serialization compiler plugin must be applied to every module that declares a route.
  5. 5Declare deep links against the route type with navDeepLink<T>(basePath), and still validate what arrives.
  6. 6Migrate one destination per pull request, argument-heavy screens before simple ones.
MOD · FAQ3 ENTRIESANSWERED

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.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

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.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA