
If you have been building Android apps with Jetpack Compose since its early days, you likely share a specific memory: the pure joy of building reactive UIs, abruptly followed by the friction of string-based navigation routes.
While the core Compose UI framework was modern and intuitive, passing complex arguments via strings in the Navigation component felt like a step backward into the dark ages of intent extras.
Because of this gap, third-party libraries became the heroes of our ecosystem. In my open-source project, Upnext: TV Series Manager, I relied heavily on excellent libraries like Compose Destinations. They hid the boilerplate, provided compile-time safety via annotations, and allowed us to focus on building UI instead of the plumbing. They taught us what Compose navigation should look like.
However, the Android platform has evolved. With recent releases (Navigation 2.8+), Google introduced first-party Type Safety powered by kotlinx.serialization. It was time for Upnext to align with the official, modern platform standards, reduce our KSP dependency footprint, and embrace explicit routing.
Here is the story of how I ripped out the magic, embraced the explicit map, and migrated Upnext to type-safe Jetpack Compose Navigation.
Part 1: Defining the Blueprint (Data Classes > Strings)
Under code-generation libraries, the “magic” happened via annotations. You would add an @Destination annotation on a composable, and KSP would do the heavy lifting.
With native Navigation 3, we move from annotations to clear, strongly-typed contracts defined by Kotlin data classes and objects.
For a simple screen with no arguments, we define a standard Kotlin data object:
import kotlinx.serialization.Serializable
@Serializable
data object DashboardScreen
For a screen that requires complex arguments, we define a data class. This is where the magic of kotlinx.serialization shines—we are no longer building URLs by string concatenation:
@Serializable
data class ShowDetail(
val showId: Int,
val showTitle: String? = null,
val showImageUrl: String? = null,
val showBackgroundUrl: String? = null
)
This simple contract is everything. It is entirely decoupled from the UI, making it highly testable and incredibly easy to read.
Part 2: Wiring the NavHost (Taking Back Explicit Control)
One of the great trade-offs of code generation is losing sight of the “big picture.” When KSP builds your navigation graph, it’s a black box. Transitioning to native Compose Navigation gave me back the explicit map of my application.
While it requires writing out the NavHost manually, the type-safe API is beautiful:
NavHost(
navController = navController,
startDestination = DashboardScreen
) {
composable<DashboardScreen> {
DashboardScreen(
onShowClick = { show ->
navController.navigate(
ShowDetail(
showId = show.id,
showTitle = show.title,
showImageUrl = show.originalImageUrl,
showBackgroundUrl = show.originalBackgroundImageUrl
)
)
}
)
}
composable<ShowDetail> { backStackEntry ->
// The UI implementation
ShowDetailScreen()
}
}
Notice the generic type parameter on composable<T>. If the route doesn’t match the object being passed, it simply will not compile. Type safety at its finest.
Part 3: Handling Arguments (The “Title Unknown” Lesson)
During my migration of the ShowDetail screen, I hit a snag. When navigating from the Dashboard to the Detail screen, the TopBar stubbornly displayed “Title Unknown”, even though I was positive I was passing the showTitle in the navigate() function.
With string-based routes, debugging this was a nightmare of checking keys. With type-safe routes, the fix was immediately apparent. I was assuming the ViewModel was the only place reading the arguments. I needed to explicitly extract the arguments in the composable layer as well to feed the UI structure before the full data is loaded.
The solution is the brilliant toRoute<T>() extension function:
composable<ShowDetail> { backStackEntry ->
// Explicitly parse the type-safe arguments from the entry
val args = backStackEntry.toRoute<ShowDetail>()
// Pass the immediately available title to the screen
ShowDetailScreen(
title = args.showTitle ?: "Title Unknown"
)
}
This exact line of code fixed the bug and highlighted why moving away from string literals is so powerful.
Part 4: Adapting to Adaptive UIs
If you read my previous article on NavigableListDetailPaneScaffold, you know I am passionate about adaptive layouts for tablets and foldables.
The best part of this type-safe migration is how cleanly it integrates with complex scaffolds. Because our state is represented by these simple @Serializable classes, sharing a single NavController between the “List” pane and the “Detail” pane, and observing the back stack to trigger ThreePaneScaffoldRole changes, becomes a highly predictable exercise.
There are no hidden generated NavControllers to fight with; just standard Kotlin data flow.
The Journey Continues
I will be forever grateful to tools like Compose Destinations. They pushed the boundary of what was possible and fundamentally shaped how the community approached Compose navigation. They paved the way.
However, aligning with official, first-party tools is a critical step in the maturity of any project. By migrating Upnext to type-safe Navigation 3, we eliminated KSP processing overhead, removed a major dependency, and most importantly, established a rock-solid, compile-time-safe standard for the future of the app.
The Evolution of Navigation: Migrating Upnext to Type-Safe Jetpack Compose Navigation 3 was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.




This Post Has 0 Comments