Navigation 2.8 shipped type-safe routes in September 2024 and I have been migrating codebases to it ever since. The API is a genuine improvement — stringly-typed routes were one of the last rough edges in Compose Navigation — but the migration is not mechanical. There are a handful of places where the new model forces you to reconsider how you structured things, and a few gotchas that are not obvious from the documentation.

This post walks through a full migration of a realistic app: authentication flow, nested graphs, deep links, and programmatic back-stack manipulation.

What changed

Before 2.8, routes were strings:

// Before — string routes
const val ROUTE_HOME = "home"
const val ROUTE_PROFILE = "profile/{userId}"

NavHost(navController, startDestination = ROUTE_HOME) {
    composable(ROUTE_HOME) { HomeScreen() }
    composable(
        route = ROUTE_PROFILE,
        arguments = listOf(navArgument("userId") { type = NavType.StringType }),
    ) { backStackEntry ->
        val userId = backStackEntry.arguments?.getString("userId") ?: return@composable
        ProfileScreen(userId)
    }
}

// Navigating
navController.navigate("profile/$userId")

The problems are obvious in retrospect: no compile-time safety, argument extraction is boilerplate-heavy, and typos in route strings fail silently at runtime.

With 2.8, routes are @Serializable data objects and classes:

// After — type-safe routes
@Serializable
object Home

@Serializable
data class Profile(val userId: String)

NavHost(navController, startDestination = Home) {
    composable<Home> { HomeScreen() }
    composable<Profile> { backStackEntry ->
        val args = backStackEntry.toRoute<Profile>()
        ProfileScreen(args.userId)
    }
}

// Navigating
navController.navigate(Profile(userId = user.id))

Arguments are constructor parameters. The serialization library encodes them into the route URL under the hood, and toRoute<T>() deserialises them back. The compiler catches mismatched types and missing arguments before the app runs.

Setup

Add the dependencies. You need kotlinx-serialization-json alongside the navigation artifact:

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

dependencies {
    implementation(libs.androidx.navigation.compose)    // 2.8.0+
    implementation(libs.kotlinx.serialization.json)
}
# libs.versions.toml
[versions]
navigation = "2.8.9"
kotlinx-serialization = "1.7.3"

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

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

Defining the route graph

Keep all route definitions in one file. This makes the graph legible and prevents routes from leaking across feature modules.

// navigation/Routes.kt
import kotlinx.serialization.Serializable

// Top-level destinations
@Serializable object Home
@Serializable object Settings

// Auth graph
@Serializable object AuthGraph
@Serializable object Login
@Serializable object Register
@Serializable data class VerifyEmail(val email: String)

// Profile graph
@Serializable object ProfileGraph
@Serializable data class Profile(val userId: String)
@Serializable data class EditProfile(val userId: String)
@Serializable data class Followers(
    val userId: String,
    val initialTab: FollowerTab = FollowerTab.FOLLOWERS,
)

enum class FollowerTab { FOLLOWERS, FOLLOWING }

One important constraint: every type used as a route argument must be serializable. Enums work out of the box. For custom types, you need a custom NavType — more on that below.

Nested navigation graphs

Nested graphs work with the same pattern. Declare the graph root as a @Serializable object and pass it to navigation<T>:

@Composable
fun AppNavHost(
    navController: NavHostController,
    isLoggedIn: Boolean,
    modifier: Modifier = Modifier,
) {
    val startDestination: Any = if (isLoggedIn) Home else AuthGraph

    NavHost(
        navController = navController,
        startDestination = startDestination,
        modifier = modifier,
    ) {
        // Auth nested graph
        navigation<AuthGraph>(startDestination = Login) {
            composable<Login> {
                LoginScreen(
                    onLoginSuccess = {
                        navController.navigate(Home) {
                            popUpTo<AuthGraph> { inclusive = true }
                        }
                    },
                    onNavigateToRegister = { navController.navigate(Register) },
                )
            }
            composable<Register> {
                RegisterScreen(
                    onRegistered = { email ->
                        navController.navigate(VerifyEmail(email)) {
                            popUpTo<Register> { inclusive = true }
                        }
                    }
                )
            }
            composable<VerifyEmail> { backStackEntry ->
                val args = backStackEntry.toRoute<VerifyEmail>()
                VerifyEmailScreen(
                    email = args.email,
                    onVerified = {
                        navController.navigate(Home) {
                            popUpTo<AuthGraph> { inclusive = true }
                        }
                    },
                )
            }
        }

        // Main destinations
        composable<Home> { HomeScreen(navController) }
        composable<Settings> { SettingsScreen() }

        // Profile nested graph
        navigation<ProfileGraph>(startDestination = Profile("")) {
            composable<Profile> { backStackEntry ->
                val args = backStackEntry.toRoute<Profile>()
                ProfileScreen(
                    userId = args.userId,
                    onEditProfile = { navController.navigate(EditProfile(args.userId)) },
                    onFollowers = { navController.navigate(Followers(args.userId)) },
                )
            }
            composable<EditProfile> { backStackEntry ->
                val args = backStackEntry.toRoute<EditProfile>()
                EditProfileScreen(userId = args.userId)
            }
            composable<Followers> { backStackEntry ->
                val args = backStackEntry.toRoute<Followers>()
                FollowersScreen(userId = args.userId, initialTab = args.initialTab)
            }
        }
    }
}

Note popUpTo<AuthGraph> { inclusive = true } — the generic version of popUpTo takes the route type directly. No more string literals in back-stack manipulation.

Custom argument types

For arguments that are not primitives or enums, you need a custom NavType. Here is a pattern for passing a Parcelable — or more practically, for passing a sealed class that represents some lightweight state:

@Serializable
sealed class TransactionFilter {
    @Serializable data object All : TransactionFilter()
    @Serializable data class ByCategory(val categoryId: String) : TransactionFilter()
    @Serializable data class DateRange(val from: Long, val to: Long) : TransactionFilter()
}

@Serializable
data class TransactionList(
    val filter: TransactionFilter = TransactionFilter.All,
)

Sealed classes with @Serializable on every subtype work without a custom NavType — the serialization library handles the polymorphism. The encoded route will contain the JSON representation as a query parameter.

Where you actually need a custom NavType is for third-party types you cannot annotate. The pattern looks like this:

inline fun <reified T : Any> serializableNavType(
    isNullableAllowed: Boolean = false,
): NavType<T> = object : NavType<T>(isNullableAllowed) {
    override fun get(bundle: Bundle, key: String): T? =
        bundle.getString(key)?.let { Json.decodeFromString(it) }

    override fun parseValue(value: String): T =
        Json.decodeFromString(Uri.decode(value))

    override fun serializeAsValue(value: T): String =
        Uri.encode(Json.encodeToString(value))

    override fun put(bundle: Bundle, key: String, value: T) =
        bundle.putString(key, Json.encodeToString(value))
}

Register it in the composable declaration:

val myType = serializableNavType<MyType>()

composable<MyDestination>(
    typeMap = mapOf(typeOf<MyType>() to myType),
) { ... }

Deep link handling integrates cleanly with the new API. Declare the pattern on the composable and the library maps the URL parameters to your route arguments automatically:

composable<Profile>(
    deepLinks = listOf(
        navDeepLink<Profile>(basePath = "https://singhangad.in/profile")
    ),
) { backStackEntry ->
    val args = backStackEntry.toRoute<Profile>()
    ProfileScreen(userId = args.userId)
}

For the URL https://singhangad.in/profile?userId=abc123, the library deserialises userId directly into Profile(userId = "abc123"). No manual Uri parsing.

Declare the intent filter in AndroidManifest.xml as before:

<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="singhangad.in" />
</intent-filter>

The navigation controller should not live in the ViewModel — it is a UI concern. Use a one-shot event channel instead:

// Shared pattern for navigation events
sealed interface NavEvent {
    data class Navigate(val route: Any) : NavEvent
    data class NavigateAndClearStack(val route: Any, val clearTo: Any) : NavEvent
    data object NavigateUp : NavEvent
}

class LoginViewModel : ViewModel() {
    private val _navEvents = Channel<NavEvent>(Channel.BUFFERED)
    val navEvents = _navEvents.receiveAsFlow()

    fun onLoginSuccess() {
        viewModelScope.launch {
            _navEvents.send(
                NavEvent.NavigateAndClearStack(
                    route = Home,
                    clearTo = AuthGraph,
                )
            )
        }
    }
}

// In the composable
@Composable
fun LoginScreen(
    viewModel: LoginViewModel = hiltViewModel(),
    onNavEvent: (NavEvent) -> Unit,
) {
    LaunchedEffect(Unit) {
        viewModel.navEvents.collect { onNavEvent(it) }
    }
    // ...
}

Handle the events in the nav host:

composable<Login> {
    LoginScreen(
        onNavEvent = { event ->
            when (event) {
                is NavEvent.Navigate -> navController.navigate(event.route)
                is NavEvent.NavigateAndClearStack -> navController.navigate(event.route) {
                    popUpTo(event.clearTo) { inclusive = true }
                }
                NavEvent.NavigateUp -> navController.navigateUp()
            }
        }
    )
}

What to watch out for

Argument size limits. Route arguments are encoded into the URL and passed through the back stack, which goes through Bundle. Android's Bundle limit is 1 MB across the whole transaction, but in practice you should treat navigation arguments as IDs, not payloads. Pass a userId, not a serialised User object. Load the full object in the destination's ViewModel.

Default arguments. Constructor defaults work:

@Serializable
data class Followers(
    val userId: String,
    val initialTab: FollowerTab = FollowerTab.FOLLOWERS,
)

When navigating without specifying initialTab, the default is used. This replaces the old defaultValue on navArgument.

Nullable arguments. Mark nullable parameters with ? in the class definition. The serialized route will omit the query parameter when the value is null, and toRoute() will deserialise it back to null correctly.

@Serializable
data class Search(val query: String? = null)

Testing. The NavController fake from navigation-testing (TestNavHostController) works with type-safe routes the same way it worked with strings. Verify navigation calls with assertEquals(Profile(userId = "123"), navController.currentBackStackEntry?.toRoute<Profile>()).

The migration order that works

If you have an existing app with string routes, do this incrementally:

  1. Add the serialization plugin and kotlinx-serialization-json dependency
  2. Pick one isolated screen (ideally a leaf — no back-stack manipulation) and convert its route to a @Serializable object
  3. Convert its composable() call to composable<T>() and replace arguments?.getString(...) with toRoute<T>()
  4. Convert the navigate("route/$arg") call at the call site to navigate(RouteClass(arg))
  5. Repeat screen by screen, converting popUpTo strings to popUpTo<T>() as you go

Do not try to convert the entire graph at once. The old and new APIs are compatible in the same NavHost, so you can ship incrementally.

support

Found this useful? You can buy me a coffee.

☕ buy me a coffee