Navigation
SkillDev toolsNavigation on the KMP Starter Template using navigation3 — AppScreens, polymorphic registration, AppNavigationModule routes, StarterNavigator, nested navigation (scoped navigators, Nav3Transitions, entry decorators), and ResultStore.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Navigation skill
What this skill tells your AI
The instructions your AI receives, as published by devatrii/kmp-starter-template in .agents/skills/kmp-starter/navigation/SKILL.md and read by ahel’s review.
Uses navigation3 + Koin. Global navigation lives in composeApp/core/navigation.
1. Define screens
composeApp/src/commonMain/kotlin/<your-package>/core/navigation/AppScreens.kt:
@Serializable
@Stable
sealed class AppScreens : NavKey {
@Serializable
data object Home : AppScreens()
}
Rules: must be @Serializable; must extend NavKey; one sealed class per feature. Mark @Stable too. Group many screens into nested sealed classes (e.g. App, Web, Plugins) to keep one hierarchy.
2. Register for serialization
In composeApp/.../App.kt, AppConfig.navigationPolymorphicBuilder:
val navigationPolymorphicBuilder: PolymorphicModuleBuilder<NavKey>.() -> Unit = {
subclass(AppScreens.Home::class)
// ... every screen
}
Missing registration → state restoration fails.
3. Define route (Koin)
composeApp/src/commonMain/kotlin/<your-package>/core/navigation/AppNavigationModule.kt:
val appNavigationModule = module {
includes(navigationCoreModule)
navigation<AppScreens.Home> { route ->
val navigator = StarterNavigator.getCurrent()
HomeScreen(onTaskComplete = { navigator.navigateUp() })
}
}
4. Navigate
val navigator = StarterNavigator.getCurrent()
navigator.navigateTo(route) // push
navigator.popAndNavigate(route) // pop current, then push
navigator.popAllAndNavigate(route) // clear stack, then push
navigator.navigateOrBringToTop(route) // single instance, bring to top
navigator.navigateUp() // pop
navigator.remove(route) // remove a destination
StarterNavigator extends BaseNavigator (features/navigation/.../BaseNavigator.kt) and is provided by Koin via navigationCoreModule.
5. Change initial screen
composeApp/.../App.kt → StarterNavigation(AppScreens.Splash, ...). First argument is the start screen.
Rules
- Feature modules never contain app navigation logic; they expose composables only.
composeAppowns navigation.- ViewModels never navigate directly; screens call navigator from callbacks.
Nested navigation
When a feature owns multiple internal screens (e.g. a bottom-nav root with several tabs), it defines its own navigation graph inside the feature. Isolate everything in a nested_navigation package.
1. Define screens
A sealed class in the feature's nested-nav package (@Serializable, extend NavKey):
@Serializable
sealed class FeatureScreens : NavKey {
@Serializable data object Home : FeatureScreens()
@Serializable data object History : FeatureScreens()
@Serializable data object Settings : FeatureScreens()
}
2. Create the Koin scope
A scope object + getOrCreateScope() so the nested navigator (and any scoped deps) live/die with the feature:
internal object FeatureNavScope {
private val QUALIFIER = qualifier<FeatureNavScope>()
private val ID = QUALIFIER.toString()
@Composable
fun getOrCreateScope() = getKoin().getOrCreateScope(scopeId = ID, qualifier = QUALIFIER)
}
3. Create the nested navigator
Extend BaseNavigator and add a getCurrent() companion that injects from the scope:
internal class FeatureNestedNavigator : BaseNavigator() {
companion object {
@Composable
fun getCurrent(): FeatureNestedNavigator =
koinInject(scope = FeatureNavScope.getOrCreateScope())
}
}
To signal the parent (e.g. "open a screen owned by the app, with args"), expose events from the navigator itself:
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val _events = MutableSharedFlow<FeatureNestedNavigationEvents>(replay = 0)
val events: SharedFlow<FeatureNestedNavigationEvents> = _events.asSharedFlow()
fun emitEvent(event: FeatureNestedNavigationEvents) {
scope.launch { _events.emit(event) }
}
internal sealed class FeatureNestedNavigationEvents {
data class OnNavigateToDetail(val id: String) : FeatureNestedNavigationEvents()
data object OnNavigateToPurchases : FeatureNestedNavigationEvents()
}
4. Register routes in a Koin module
One module per feature graph, scope<FeatureNavScope> { scoped { Navigator() } + navigation<Screen> { ... } }:
val featureNestedNavigationModule = module {
scope<FeatureNavScope> {
scoped { FeatureNestedNavigator() }
navigation<FeatureScreens.Home> {
val navigator = FeatureNestedNavigator.getCurrent()
HomeScreen(
onNavigateToDetail = { id ->
navigator.emitEvent(FeatureNestedNavigationEvents.OnNavigateToDetail(id))
},
onNavigateToPurchases = {
navigator.emitEvent(FeatureNestedNavigationEvents.OnNavigateToPurchases)
},
)
}
navigation<FeatureScreens.History> { HistoryScreen() }
navigation<FeatureScreens.Settings> {
val navigator = FeatureNestedNavigator.getCurrent()
SettingsScreen(
onNavigateToPurchases = {
navigator.emitEvent(FeatureNestedNavigationEvents.OnNavigateToPurchases)
},
)
}
}
}
5. Root screen
The root composable holds the back stack, wires the navigator, and observes navigator events to bubble navigation up to the app:
@Composable
fun FeatureRootScreen(
onNavigateToDetail: (id: String) -> Unit,
onNavigateToPurchases: () -> Unit,
) {
FeatureRootScreenContent()
val navigator = FeatureNestedNavigator.getCurrent()
ObserveAsEvents(flow = navigator.events) { event ->
when (event) {
is FeatureNestedNavigationEvents.OnNavigateToDetail ->
onNavigateToDetail(event.id)
FeatureNestedNavigationEvents.OnNavigateToPurchases -> onNavigateToPurchases()
}
}
}
@Composable
private fun FeatureRootScreenContent() {
val koinScope = rememberKoinScope(scope = FeatureNavScope.getOrCreateScope())
val entryProvider = koinEntryProvider<Any>(scope = koinScope)
val backStack: NavBackStack<NavKey> = rememberNavBackStack(FeatureScreens.Home) {
subclass(FeatureScreens.Home::class)
subclass(FeatureScreens.History::class)
subclass(FeatureScreens.Settings::class)
}
val navigator = FeatureNestedNavigator.getCurrent()
LaunchedEffect(backStack) { navigator.provideBackStack(backStack) }
Scaffold(bottomBar = {
// tab bar; each tab -> navigator.navigateOrBringToTop(item.route)
}) { innerPaddings ->
NavDisplay(
modifier = Modifier.padding(bottom = innerPaddings.calculateBottomPadding()),
backStack = backStack,
entryProvider = entryProvider,
transitionSpec = Nav3Transitions.horizontalSlideParallax(),
popTransitionSpec = Nav3Transitions.horizontalSlideParallaxPop(),
predictivePopTransitionSpec = Nav3Transitions.predictiveHorizontalSlideParallax(),
)
}
}
Key points:
- Use
rememberKoinScope(scope = FeatureNavScope.getOrCreateScope())+koinEntryProvider<Any>(scope = koinScope)— the entry provider is scoped, so screen-level ViewModels resolve inside the feature scope. - Bind navigator with
scoped { FeatureNestedNavigator() }in the scope; retrieve withgetCurrent()(which injects from the same scope). - Tab switching uses
navigator.navigateOrBringToTop(route)(single instance per tab). - Bubble "leave the feature" actions up via navigator
events+ObserveAsEvents— feature modules never touch app navigation directly. - Transitions via
Nav3Transitions(horizontalSlideParallax/fade/ etc.). Pick per platform if needed (platform.isAndroid/platform.isIos).
Register the feature's *NestedNavigationModule in InitKoin (see koin skill).
Flow
- Create screen (
@Serializable @Stable sealed class : NavKey) - Register in
navigationPolymorphicBuilder - Add route in
AppNavigationModule(or custom module) - Use
StarterNavigator - Change initial screen if needed
Screen-to-screen results (ResultStore)
To send a result back or signal a parent without a direct nav-arg (e.g. "open the drawer" from a child), use ResultStore (features/navigation/.../utils/ResultStore.kt).
val resultStore = rememberResultStore()
ProvideResultStore(resultStore) { ... } // in the parent
// child: set a result
val store = LocalResultStore.current
scope.launch { store.setResult("open_drawer", true) }
// parent: observe it
ObserveAsEvents(store.observeResult<Boolean>("open_drawer")) { open ->
if (open) drawerState.open()
}
Use a constant string key per result. Results are ephemeral (not saved across process death).
Reference
features/navigation/.../BaseNavigator.kt,StarterNavigator.kt,StarterNavigation.ktcomposeApp/.../core/navigation/AppScreens.kt,AppNavigationModule.kt- Docs:
https://starter.atherio.dev/fundamentals/08-navigation/
Signals
- GitHub stars
- 162
- Forks
- 28
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
kmp-starter-navigation- Source
- github.com/devatrii/kmp-starter-template