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 links
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>
Navigating from a ViewModel
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:
- Add the serialization plugin and
kotlinx-serialization-jsondependency - Pick one isolated screen (ideally a leaf — no back-stack manipulation) and convert its route to a
@Serializableobject - Convert its
composable()call tocomposable<T>()and replacearguments?.getString(...)withtoRoute<T>() - Convert the
navigate("route/$arg")call at the call site tonavigate(RouteClass(arg)) - Repeat screen by screen, converting
popUpTostrings topopUpTo<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.