Type-safe Compose Navigation for Android/KMP - route objects, feature nav graphs, cross-feature callbacks, and wiring in :app. Use this skill whenever setting up navigation, defining routes, adding a new screen to a nav graph, navigating between features, or wiring nav graphs in the app module. Trigger on phrases like "set up navigation", "add a route", "navigate between screens", "nav graph", "NavController", "type-safe nav", "cross-feature navigation", or "NavGraphBuilder".
@Serializable route objects (KotlinX Serialization).presentation module.:app.NavController passed into the feature nav graph.Define routes as @Serializable objects or data classes in the feature's presentation module:
@Serializable object NoteListRoute // no parameters
@Serializable data class NoteDetailRoute(val noteId: String) // with parameter
Use data object for screens with no parameters, for screens with arguments.
data classEach feature exposes a NavGraphBuilder extension function. In nav graphs, use *Screen composables (stateful — hold ViewModel):
fun NavGraphBuilder.notesGraph(
navController: NavController,
onNavigateToEditor: (String) -> Unit // cross-feature callback
) {
navigation<NoteListRoute>(startDestination = NoteListRoute) {
composable<NoteListRoute> {
NoteListScreen( // *Screen = stateful composable
onNavigateToDetail = { navController.navigate(NoteDetailRoute(it)) }
)
}
composable<NoteDetailRoute> { backStackEntry ->
val route: NoteDetailRoute = backStackEntry.toRoute()
NoteDetailScreen(
noteId = route.noteId,
onNavigateToEditor = onNavigateToEditor
)
}
}
}
:appAll feature nav graphs are assembled in one place:
NavHost(navController, startDestination = NoteListRoute) {
notesGraph(
navController = navController,
onNavigateToEditor = { navController.navigate(EditorRoute(it)) }
)
editorGraph(navController)
authGraph(
onNavigateToHome = { navController.navigate(NoteListRoute) }
)
}
Cross-feature navigation is always expressed as a lambda callback — never by importing a route from another feature module.
For simple scalar arguments, use @Serializable data class routes:
@Serializable data class NoteDetailRoute(val noteId: String)
// Navigate
navController.navigate(NoteDetailRoute(noteId = "abc123"))
// Receive in ViewModel
class NoteDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
repository: NoteRepository
) : ViewModel() {
private val noteId = savedStateHandle.toRoute<NoteDetailRoute>().noteId
}
Avoid passing complex objects via navigation — pass IDs and load data in the destination ViewModel.
| Thing | Convention | Example |
|---|---|---|
| Nav route | <Screen>Route | NoteListRoute, NoteDetailRoute |
| Feature nav graph | <feature>Graph(...) on NavGraphBuilder | notesGraph(...) |
@Serializable route objects for each screen in feature:presentationNavGraphBuilder.<feature>Graph(...))*Screen composables (stateful) in nav graph — never *ContentNavController for intra-feature navigation:app's NavHost