Type-Safe Navigation Compose with Kotlin Serialization

Quick answer: Define each Navigation Compose destination as an @Serializable Kotlin object or data class, use that type in NavHost and composable<T>, navigate with an instance such as navController.navigate(Profile(userId)), and decode it with backStackEntry.toRoute<T>(). Navigation’s type-safe route APIs require Navigation 2.8.0 or newer.

String routes are easy to start with, but they make the navigation contract implicit. A typo in a route name, a missing argument, or a value parsed as the wrong type can remain hidden until a user opens that destination. Type-safe Navigation Compose moves the destination and its arguments into serializable Kotlin types, so the graph and navigation calls share one contract.

This guide builds a small home-to-profile flow, then covers dependency setup, ViewModel arguments, nested graphs, migration, and the boundaries of what should be passed through a route.

What type-safe Navigation Compose changes

The type-safe API does not replace NavController or NavHost. It changes how destinations are represented:

ConcernString-based navigationType-safe navigation
Destination identityA string such as "profile/{userId}"A serializable Kotlin type
ArgumentsNavArgument declarations and lookupsConstructor properties
Navigation callA manually assembled stringA route instance
Reading argumentsarguments?.getString(...)toRoute<T>()

The APIs are available in Navigation Compose and the Navigation Kotlin DSL starting with Navigation 2.8.0. They are conceptually similar to the compile-time argument safety that Safe Args provides for XML navigation graphs, but the route contract here is expressed with Kotlin serialization types. See the official type-safety documentation for the API contract.

Add the required dependencies

Add navigation-compose at version 2.8.0 or newer. You must also apply the Kotlin serialization plugin and include the JSON runtime. The exact Kotlin and library versions should follow the version catalog or dependency policy of your project.

With a version catalog, the relevant entries look like this:

// gradle/libs.versions.toml
[versions]
kotlin = "<your-kotlin-version>"
navigation = "<your-navigation-version>"
kotlinxSerialization = "<your-kotlinx-serialization-version>"

[libraries]
navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Apply the plugin in the module that declares the route classes, then add the dependencies:

// app/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.serialization)
}

dependencies {
    implementation(libs.navigation.compose)
    implementation(libs.kotlinx.serialization.json)
}

The serialization plugin generates the serializer used by the navigation library. Adding only kotlinx-serialization-json is not enough: without the plugin, your route types do not get the generated serializers they need.

Define destinations as serializable types

Use an object for a destination with no arguments and a data class for a destination with arguments:

import kotlinx.serialization.Serializable

@Serializable
data object Home

@Serializable
data class Profile(
    val userId: String,
)

The Profile constructor is now the argument contract. If the screen needs a different value later, changing this type makes the call sites that need updating visible to the compiler.

For a route without arguments, object or data object avoids creating an instance with meaningless state. A route with an argument should generally be a data class. Every route type used by the type-safe APIs must be serializable.

Keep route types small. Pass an identifier, filter value, or other small navigation input—not an entire database entity or large UI model. The destination can use that identifier to load current data from its repository or ViewModel. This follows the same separation between screen state and navigation state described in State Hoisting in Jetpack Compose.

Build a typed NavHost

The generic composable<T> builder associates a destination type with a composable. Navigation uses an instance of that same type, and toRoute<T>() reconstructs the route from the back-stack entry:

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute

@Composable
fun AppNavigation(
    modifier: Modifier = Modifier,
) {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Home,
        modifier = modifier,
    ) {
        composable<Home> {
            HomeScreen(
                onOpenProfile = { userId ->
                    navController.navigate(Profile(userId))
                },
            )
        }

        composable<Profile> { backStackEntry ->
            val profile = backStackEntry.toRoute<Profile>()

            ProfileScreen(
                userId = profile.userId,
                onBack = { navController.popBackStack() },
            )
        }
    }
}

There is no "profile/{userId}" pattern to keep synchronized with an argument lookup. The route type appears in three places with distinct responsibilities:

  1. startDestination = Home selects the initial destination.
  2. composable<Profile> registers the destination in the graph.
  3. navigate(Profile(userId)) creates a valid destination request.

toRoute<Profile>() reads the serialized arguments from the NavBackStackEntry and returns a Profile value. If you rename userId or change its type, the Kotlin code that constructs or reads the route is easier to find and update.

Keep navigation out of leaf composables

Pass intent callbacks into screen UI instead of passing NavController to every button, list item, or reusable component:

import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable

@Composable
fun HomeScreen(
    onOpenProfile: (String) -> Unit,
) {
    Button(onClick = { onOpenProfile("user-42") }) {
        Text("Open profile")
    }
}

@Composable
fun ProfileScreen(
    userId: String,
    onBack: () -> Unit,
) {
    Button(onClick = onBack) {
        Text("Back from $userId")
    }
}

This keeps the UI easy to preview and test. The graph owns the implementation of onOpenProfile, while HomeScreen only reports what the user asked to do. It also lets you reuse the same screen in a different shell, such as a list-detail layout.

Read typed arguments in a ViewModel

For a destination whose data is owned by a ViewModel, decode the route from SavedStateHandle instead of extracting arguments in the composable:

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.navigation.toRoute

class ProfileViewModel(
    savedStateHandle: SavedStateHandle,
    private val userRepository: UserRepository,
) : ViewModel() {
    private val route = savedStateHandle.toRoute<Profile>()

    val user = userRepository.observeUser(route.userId)
}

UserRepository is illustrative application code, not part of Navigation Compose. The important part is that the ViewModel receives the same typed route contract as the destination. It then loads the current profile rather than receiving a potentially stale copy of a full User object through navigation.

The official Navigation type-safety guide documents both NavBackStackEntry.toRoute() and SavedStateHandle.toRoute().

Use typed routes with feature graphs

As a graph grows, keep route declarations close to their feature and let the app-level NavHost assemble the graph. A graph-builder extension can still use the navigation controller for destinations that belong inside that feature:

import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import kotlinx.serialization.Serializable

@Serializable
data object Settings

fun NavGraphBuilder.settingsGraph(
    navController: NavController,
) {
    composable<Settings> {
        SettingsScreen(
            onOpenProfile = { userId ->
                navController.navigate(Profile(userId))
            },
        )
    }
}

In a multi-module app, be deliberate about route ownership. A feature can expose navigation events or a small graph-builder function without forcing every UI component to depend on the app’s NavController. For a small project, one readable graph is usually enough; split it when feature boundaries make the destination list easier to understand.

If your app uses an app shell, place the NavHost inside the appropriate Scaffold content and preserve the scaffold’s insets. The Scaffold and Window Insets guide covers that layout relationship.

Migrate from string routes

The migration is mechanical when a destination already has a clear argument contract.

Before:

const val PROFILE_ROUTE = "profile/{userId}"

NavHost(navController, startDestination = "home") {
    composable("home") {
        HomeScreen { userId ->
            navController.navigate("profile/$userId")
        }
    }

    composable(PROFILE_ROUTE) { entry ->
        val userId = entry.arguments?.getString("userId")
        ProfileScreen(userId = requireNotNull(userId), onBack = navController::popBackStack)
    }
}

After:

@Serializable
data object Home

@Serializable
data class Profile(val userId: String)

NavHost(navController, startDestination = Home) {
    composable<Home> {
        HomeScreen { userId ->
            navController.navigate(Profile(userId))
        }
    }

    composable<Profile> { entry ->
        val profile = entry.toRoute<Profile>()
        ProfileScreen(profile.userId, navController::popBackStack)
    }
}

Migrate one graph at a time. Remove the old string constant only after its destinations and all navigation calls use the typed route. Avoid keeping two route definitions for the same destination: that creates two sources of truth and makes future changes less safe.

Arguments, defaults, and custom types

Route properties can represent the types supported by Navigation’s type-safe argument machinery. Simple primitives and commonly used collections are the easiest choice. A nullable or defaulted property can be useful for optional query-like state, but decide whether that state belongs in the back stack at all.

For a complex custom value, do not automatically serialize a large object into a route. If the value is genuinely part of the destination identity, Navigation supports custom NavType mappings through the typeMap parameter. That is an advanced integration: the custom type must define how values are read from and written to the navigation arguments, and the graph must register the mapping consistently.

For most screens, the more maintainable choice is a small route such as Profile(userId) or Search(query), followed by loading the rest of the data in the destination. Navigation arguments are a transport mechanism, not a replacement for your repository or saved UI state.

Common mistakes

Adding the runtime but not the serialization plugin

kotlinx-serialization-json alone does not generate serializers for your route classes. Apply org.jetbrains.kotlin.plugin.serialization to the module that owns them and make sure its Kotlin plugin version matches your project.

Forgetting @Serializable

Every route used with the type-safe APIs must be serializable. Put the annotation directly on each route object or class rather than assuming a containing class makes nested types serializable.

Passing a full model through navigation

Pass a stable ID or small key and load the model in the destination. This keeps the back stack smaller and makes it possible for the destination to show updated data.

Mixing string and typed versions indefinitely

String routes and typed routes can coexist during a staged migration, but each destination should have one canonical representation. Mixing a typed destination with manually parsed arguments defeats much of the benefit.

Use popBackStack() for a back action. Calling navigate() to the previous route creates a new entry instead of removing the current one and can produce an unexpected back-stack loop.

Verification checklist

Before shipping a typed graph, verify:

  • Navigation Compose is at least 2.8.0 and the serialization plugin is applied to the correct module.
  • Every typed destination has @Serializable and appears in the intended graph.
  • Required arguments are passed through the route constructor and decoded with toRoute<T>().
  • System Back and custom back actions pop the existing entry.
  • Screens expose callbacks and UI state rather than a NavController parameter.
  • A process-death or state-restoration test confirms that the destination can reconstruct its route and reload its data.

The final item matters because type safety checks the shape of the route; it does not test whether the destination’s repository, ViewModel, or restored UI state behaves correctly. Add navigation UI tests for the user flows that matter to your app.

FAQ

What Navigation version supports type-safe routes?

Navigation 2.8.0 introduced the type-safe route APIs. Use a newer compatible version when your project allows it, and check the current AndroidX release notes before upgrading.

Do I still need NavArgument?

Not for the ordinary arguments represented by a serializable route type. The route constructor properties provide the argument contract. Custom argument types may still require a NavType mapping.

Can I use type-safe routes with a ViewModel?

Yes. Use SavedStateHandle.toRoute<YourRoute>() in the ViewModel to retrieve the typed route and use its small arguments to load screen data.

Is this the same as Navigation 3?

No. This article uses the type-safe APIs in Navigation Compose’s NavHost graph. Navigation 3 is a separate API surface with a different architecture; see the existing Navigation 3 guide when evaluating that option.