Navix
A production-grade, Compose-first navigation platform for Android and Kotlin Multiplatform.
Navix is not a wrapper around NavController. It is a standalone navigation runtime built around
a deterministic state machine, with zero reflection, first-class telemetry, built-in devtools,
and a KMP-portable core.

Quick Start
1. Add dependencies
2. Define routes
@Serializable
@RouteDestination(deepLinks = ["myapp://product/{productId}"])
data class ProductDetail(val productId: String) : Route
@Serializable
data object Home : Route
3. Set up navigation
@Composable
fun App() {
val navigator = rememberNavigator(root = Home)
NavixHost(navigator = navigator) {
screen<Home> { _, _ ->
HomeScreen(onProductClick = { id ->
navigator.push(ProductDetail(id), NavTransitionKey.SlideLeft)
})
}
screen<ProductDetail> { _, route ->
ProductDetailScreen(productId = route.productId)
}
}
}
4. Navigate
navigator.push(ProductDetail("123"))
navigator.push(ProductDetail("123"), NavTransitionKey.SlideLeft)
navigator.pop()
navigator.replace(Settings)
navigator.reset(Login)
navigator.popTo<Home>()
navigator.handleDeepLink("myapp://product/123")
Module Overview
Telemetry
NavixTelemetryPipeline fans out every NavEvent to all registered exporters asynchronously.
Events are buffered — navigation is never blocked waiting for exporter I/O.
val telemetry = NavixTelemetryPipeline(
exporters = listOf(
LogcatExporter(),
MyFirebaseExporter(),
InMemoryEventExporter(),
)
)
val navigator = rememberNavigator(root = Home, telemetry = telemetry)
Implement NavEventExporter to route events to any backend:
class MyFirebaseExporter : NavEventExporter {
override fun export(event: NavEvent) {
Firebase.analytics.logEvent(event.type.name) {
param("from", event.from?.route?.let { it::class.simpleName } ?: "")
param("to", event.to?.route?.let { it::class.simpleName } ?: "")
}
}
}
An in-memory exporter is useful for surfacing event history inside the app itself — it holds
a StateFlow<List<NavEvent>> that a screen can observe directly, unlike the hot SharedFlow
on Navigator.events which loses events emitted before a subscriber attaches:
class InMemoryEventExporter(private val maxEvents: Int = 100) : NavEventExporter {
private val _events = MutableStateFlow<List<NavEvent>>(emptyList())
val events: StateFlow<List<NavEvent>> = _events.asStateFlow()
override fun export(event: NavEvent) {
_events.update { (listOf(event) + it).take(maxEvents) }
}
}
DevTools
Add the overlay above your NavixHost content. It auto-disables in release builds.
Box(Modifier.fillMaxSize()) {
NavixHost(navigator = navigator) { }
NavixDevToolsOverlay(navigator = navigator)
}
The overlay shows:
- Live backstack with lifecycle states
- Navigation event timeline
- Route timing and transition keys
Testing
FakeNavigator is a drop-in Navigator that records all calls and provides assertion helpers:
val nav = FakeNavigator(root = Home)
nav.push(ProductDetail("42"))
nav.assertCurrentRoute(ProductDetail("42"))
nav.assertBackstackSize(2)
nav.assertLastPushed(ProductDetail("42"))
nav.assertCanPop(true)
nav.assertPushCount(1)
nav.reset(Home)
nav.assertBackstackSize(1)
nav.assertCanPop(false)
Use it in ViewModel tests with Turbine to verify nav effects:
@Test
fun `onProductClicked emits correct nav effect`() = runTest {
val vm = HomeViewModel(GetProductsUseCase(FakeProductRepository()))
vm.navEffect.test {
vm.onProductClicked("42")
val effect = awaitItem()
assertEquals("42", (effect as HomeNavEffect.OpenProductDetail).productId)
}
}
Use it in Compose UI tests to assert navigation outcomes without a real back stack:
class HomeScreenTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun `clicking product navigates to detail`() {
val navigator = FakeNavigator(root = Home)
composeRule.setContent {
NavixHost(navigator = navigator) {
screen<Home> { _, _ -> HomeScreen(navigator) }
}
}
composeRule.onNodeWithText("Kotlin Multiplatform Guide").performClick()
navigator.assertLastPushed(ProductDetail("p-001"))
navigator.assertBackstackSize(2)
}
}
Deep Links
Annotate your route with deep link URI templates:
@Serializable
@RouteDestination(deepLinks = ["myapp://product/{productId}"])
data class ProductDetail(val productId: String) : Route
The KSP compiler generates a DeepLinkHandler automatically. Register it:
val navigator = rememberNavigator(
root = Home,
deepLinkHandlers = listOf(ProductDetailDeepLinkHandler()),
)
Handle the incoming intent in your Activity:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navigator = rememberNavigator(root = Home, deepLinkHandlers = handlers)
LaunchedEffect(intent) {
intent?.data?.toString()?.let { uri -> navigator.handleDeepLink(uri) }
}
NavixHost(navigator) { }
}
}
Multi-Module Projects
Multi-module graph composition requires no annotations. Set the navix.moduleName KSP argument
in each subproject's build.gradle.kts — the compiler uses it to generate a uniquely-named
NavixRouteRegistry per module, preventing collisions:
ksp {
arg("navix.moduleName", "Checkout")
}
This generates CheckoutNavixRouteRegistry. Compose all modules' registries at the NavixHost
call site. No per-file annotation ceremony is needed.
State Restoration
Use rememberSaveableNavigator instead of rememberNavigator and the entire navigation
state survives configuration changes and process death — automatically:
val navigator = rememberSaveableNavigator(
root = Home,
saver = JsonNavigatorSaver(AppNavixSerializersModule),
)
NavixHost(navigator = navigator) { }
What survives a process-death restore:
Routes must be @Serializable so the snapshot can be persisted; the
${Module}NavixSerializersModule generated by KSP wires the polymorphic serializers.
Popped entries are evicted from the saved blob automatically, so its size tracks the live
backstack depth (not total navigations). If the saved blob can't be read (e.g. a schema
change across an app update), restore falls back to a fresh navigator at root — never a
crash.
Multi-stack (bottom navigation)
rememberSaveableNavixMultiStack persists the active tab index and every tab's
backstack in addition to per-entry state:
val multiStack = rememberSaveableNavixMultiStack(
specs = listOf(
NavStackSpec(HomeRoot, key = "home"),
NavStackSpec(SearchRoot, key = "search"),
NavStackSpec(ProfileRoot, key = "profile")
),
saver = JsonNavigatorSaver(AppNavixSerializersModule)
)
NavixMultiStackHost(multiStack) { }
Give each NavStackSpec a stable, unique key — per-tab restore is keyed by it, not by
list order.
Custom Transitions
Pass a NavTransitionSpec to NavixHost to override enter/exit animations per
NavTransitionKey. Use this to remap what the Default key means globally, or to handle
custom keys your app defines:
Built-in keys: Default, None, Fade, SlideLeft, SlideRight, Scale.
Custom keys: NavTransitionKey("my_key").
Custom Backstack Reducer
The BackstackReducer type alias ((BackstackSnapshot, BackstackAction) -> BackstackSnapshot)
is an escape hatch for advanced stack behavior. Pass it to rememberNavigator:
val SingleTopReducer: BackstackReducer = { snapshot, action ->
if (action is BackstackAction.Push) {
val existingIndex = snapshot.entries.indexOfLast { it.route::class == action.route::class }
if (existingIndex >= 0) {
DefaultBackstackReducer(snapshot, BackstackAction.PopTo(action.route::class, inclusive = ))
} {
DefaultBackstackReducer(snapshot, action)
}
} {
DefaultBackstackReducer(snapshot, action)
}
}
navigator = rememberNavigator(root = Home, reducer = SingleTopReducer)
Disabling Telemetry
Pass NavixTelemetry.NoOp to produce zero overhead when telemetry is off:
val navigator = rememberNavigator(
root = Home,
telemetry = if (analyticsEnabled) myPipeline else NavixTelemetry.NoOp
)
For a stable reference that can switch at runtime without recreating the navigator, use a
delegating wrapper:
val telemetry = object : NavixTelemetry {
override fun onEvent(event: NavEvent) {
if (analyticsEnabled.value) pipeline.onEvent(event)
}
}
Multiple Deep Link Handlers
Register handlers in priority order — the first match wins:
val navigator = rememberNavigator(
root = Home,
deepLinkHandlers = listOf(
ProductDeepLinkHandler(),
ProfileDeepLinkHandler(),
)
)
Trigger deep links programmatically from inside the app:
navigator.handleDeepLink("navix://product/42")
KMP Support
The navigation state machine (BackstackStore, NavigatorImpl, BackstackReducer) is in
commonMain and compiles without the Android SDK. The Compose layer (NavixHost,
rememberNavigator) lives in androidMain.
Future non-Android KMP targets (Desktop, iOS via Compose Multiplatform) are supported by
providing a platform-specific NavixHost equivalent.
Contributing
See CONTRIBUTING.md for module responsibilities, test requirements,
and architecture invariants that every PR must respect.
License
Copyright 2026 Navix Contributors
Licensed under the Apache License, Version 2.0
See LICENSE for the full license text.