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.
// build.gradle.kts
// Required when using navix-compiler — sets the generated registry class name.
// Must be unique per module in multi-module projects (e.g., "checkout", "profile").
ksp {
arg("navix.moduleName", "app")
}
dependencies {
// Required — backstack engine, NavixHost, Navigator, and all core APIs.
implementation("io.github.vaxters:navix-runtime:$navixVersion")
// Required — KSP processor. Generates NavixRouteRegistry and DeepLinkHandler
// implementations from your @RouteDestination-annotated routes at build time.
ksp("io.github.vaxters:navix-compiler:$navixVersion")
// Optional — navigation event pipeline with pluggable exporters (Logcat, Firebase, …).
// Omit if you don't need analytics or in-app event history.
implementation("io.github.vaxters:navix-telemetry:$navixVersion")
// Optional — in-app debug overlay: live backstack inspector + event timeline.
// Safe to ship; the overlay is a no-op when enabled = false (default).
// debugImplementation keeps it out of release APKs entirely.
debugImplementation("io.github.vaxters:navix-devtools:$navixVersion")
// Optional — FakeNavigator and Compose test helpers for unit and UI tests.
testImplementation("io.github.vaxters:navix-testing:$navixVersion")
}@Serializable
@RouteDestination(deepLinks = ["myapp://product/{productId}"])
data class ProductDetail(val productId: String) : Route
@Serializable
data object Home : Route@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)
}
}
}// Push a new screen
navigator.push(ProductDetail("123"))
// Push with a specific transition
navigator.push(ProductDetail("123"), NavTransitionKey.SlideLeft)
// Pop
navigator.pop()
// Replace current screen
navigator.replace(Settings)
// Clear stack and start over
navigator.reset(Login)
// Pop back to a specific route
navigator.popTo<Home>()
// Handle a deep link URI
navigator.handleDeepLink("myapp://product/123")| Module | Description | KMP |
|---|---|---|
contracts |
Shared data types (Route, RouteEntry, NavEvent, …) |
✅ |
navix-annotations |
@RouteDestination (source retention) |
✅ |
navix-runtime |
Backstack engine + Compose NavixHost |
Core: ✅ |
navix-compiler |
KSP processor — route discovery, deep link generation | Build-time |
navix-telemetry |
Event pipeline with pluggable exporters | ✅ |
navix-devtools |
Live backstack inspector + event timeline overlay | Android |
navix-testing |
FakeNavigator + Compose test helpers |
Core: ✅ |
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(), // built-in
MyFirebaseExporter(), // your implementation
InMemoryEventExporter(), // retain history for in-app inspection
)
)
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) }
}
}Add the overlay above your NavixHost content. It auto-disables in release builds.
Box(Modifier.fillMaxSize()) {
NavixHost(navigator = navigator) { /* screens */ }
NavixDevToolsOverlay(navigator = navigator) // debug only
}The overlay shows:
- Live backstack with lifecycle states
- Navigation event timeline
- Route timing and transition keys
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)
}
}Annotate your route with deep link URI templates:
@Serializable
@RouteDestination(deepLinks = ["myapp://product/{productId}"])
data class ProductDetail(val productId: String) : RouteThe KSP compiler generates a DeepLinkHandler automatically. Register it:
val navigator = rememberNavigator(
root = Home,
deepLinkHandlers = listOf(ProductDetailDeepLinkHandler()), // KSP-generated
)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) { /* screens */ }
}
}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:
// checkout/build.gradle.kts
ksp {
arg("navix.moduleName", "Checkout")
}This generates CheckoutNavixRouteRegistry. Compose all modules' registries at the NavixHost
call site. No per-file annotation ceremony is needed.
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), // KSP-generated module
)
NavixHost(navigator = navigator) { /* screen<…> { } */ }What survives a process-death restore:
| State | Mechanism |
|---|---|
| Backstack (every route + its arguments) | serialised by the NavigatorSaver |
ViewModel SavedStateHandle |
per-entry SavedStateRegistry, restored into a recreated entry |
Screen rememberSaveable { } |
per-entry, entry-id-keyed saved-state holder |
Entry-scoped ViewModel instances (config change) |
host-Activity-scoped store, retained across recreation — no serialization cost on rotation |
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.
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) { /* screen<…> { } shared across tabs */ }Give each NavStackSpec a stable, unique key — per-tab restore is keyed by it, not by
list order.
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:
val mySpec = object : NavTransitionSpec {
override fun enterTransition(from: RouteEntry?, to: RouteEntry, key: NavTransitionKey) =
when (key) {
NavTransitionKey.Default -> slideInHorizontally { it }
NavTransitionKey.Scale -> scaleIn(initialScale = 0.9f) + fadeIn()
else -> fadeIn()
}
override fun exitTransition(from: RouteEntry, to: RouteEntry?, key: NavTransitionKey) =
when (key) {
NavTransitionKey.Default -> slideOutHorizontally { -it }
NavTransitionKey.Scale -> scaleOut(targetScale = 1.1f) + fadeOut()
else -> fadeOut()
}
}
NavixHost(navigator = navigator, transitionSpec = mySpec) { /* screens */ }Built-in keys: Default, None, Fade, SlideLeft, SlideRight, Scale.
Custom keys: NavTransitionKey("my_key").
The BackstackReducer type alias ((BackstackSnapshot, BackstackAction) -> BackstackSnapshot)
is an escape hatch for advanced stack behavior. Pass it to rememberNavigator:
// Single-top: navigating to a route type already on the stack pops to it instead.
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 = false))
} else {
DefaultBackstackReducer(snapshot, action)
}
} else {
DefaultBackstackReducer(snapshot, action)
}
}
val navigator = rememberNavigator(root = Home, reducer = SingleTopReducer)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)
}
}Register handlers in priority order — the first match wins:
val navigator = rememberNavigator(
root = Home,
deepLinkHandlers = listOf(
ProductDeepLinkHandler(), // navix://product/{id}
ProfileDeepLinkHandler(), // navix://profile
)
)Trigger deep links programmatically from inside the app:
navigator.handleDeepLink("navix://product/42")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.
See CONTRIBUTING.md for module responsibilities, test requirements, and architecture invariants that every PR must respect.
Copyright 2026 Navix Contributors
Licensed under the Apache License, Version 2.0
See LICENSE for the full license text.