reaktiv-navigation
The Navigation module provides a type-safe, state-driven navigation system for Kotlin Multiplatform projects. It integrates with Reaktiv Core to manage screen transitions, back-stack, modals, deep linking, and navigation guards — all as pure state.
Setup
// build.gradle.kts
dependencies {
implementation("io.github.syrou:reaktiv-navigation:<version>")
}Defining Screens
A Screen is a single destination with a route, optional transitions, and a Composable Content function.
object HomeScreen : Screen {
override val route = "home"
override val enterTransition = NavTransition.Fade
override val exitTransition = NavTransition.FadeOut
@Composable
override fun Content(params: Params) {
Text("Home")
}
}
object ProfileScreen : Screen {
override val route = "profile/{userId}"
override val enterTransition = NavTransition.SlideInRight
override val exitTransition = NavTransition.SlideOutLeft
@Composable
override fun Content(params: Params) {
val userId = params.getString("userId")
ProfileView(userId)
}
}Routes and Path Params
A route is a path template, and it works like a route in any web router. Its full path is the route of every graph around it followed by its own route, so ProfileScreen registered in graph("main") lives at main/profile/{userId}.
A
{name}segment is a path param. Its value is always text, taken from the path and decoded once, somain/profile/Ada%20LgivesuserId = "Ada L". A+in a path stays a+.A segment can mix text and a placeholder, such as
item-{id}, and a route may start with a placeholder, such as{slug}.A route without placeholders wins over a parameterized one. Between parameterized routes, the one with a static segment earliest wins, so
en/{page}beats{lang}/homeforen/home.
Navigate with the screen object and its values instead of writing the graph path by hand, and ask for the location when you need a link to it:
store.navigation { navigateTo(ProfileScreen, "userId" to 42) }
val link = store.locationOf(ProfileScreen, "userId" to 42)Here link is main/profile/42. Navigating to a screen without a value for every path param throws MissingPathParamsException, which names the missing keys.
Each back-stack entry carries three forms of where it is:
entry.pathis the template,main/profile/{userId}. It identifies the screen.entry.locationis the concrete path with values encoded,main/profile/Ada%20L. Navigating to it, or passing it topopUpTo, reaches that exact entry.NavigationState.currentFullPathis the readable form of the current location,main/profile/Ada L, for display.
Params that are not in the route, including typed objects passed with put<T>(), travel with the entry inside the app. They are never part of a location or URL, so a screen that must be reachable from a link should carry an id in its path and load the object from it.
Building the Navigation Module
Use createNavigationModule { rootGraph { ... } } to wire all screens, graphs, and guards into a single NavigationModule ready to be registered in the store.
val navigationModule = createNavigationModule {
rootGraph {
start(HomeScreen)
screens(HomeScreen)
graph("main") {
start(HomeScreen)
screens(HomeScreen, ProfileScreen)
}
}
notFoundScreen(NotFoundScreen)
}
val store = createStore {
module(navigationModule)
}Navigation Guards with intercept
intercept wraps child graphs and screens inside a guard. The guard is evaluated before navigation is committed to any route inside the block. Return GuardResult.Allow to permit the navigation, or GuardResult.RedirectTo(screen) to redirect.
val navigationModule = createNavigationModule {
rootGraph {
start(HomeScreen)
screens(HomeScreen, LoginScreen)
// Routes outside intercept are publicly accessible
// Routes inside intercept require the guard to pass
intercept(
guard = { store ->
val authState = store.selectState<AuthState>().first()
if (authState.isLoggedIn) GuardResult.Allow
else GuardResult.RedirectTo(LoginScreen)
}
) {
graph("workspace") {
start(DashboardScreen)
screens(DashboardScreen, SettingsScreen)
}
graph("admin") {
start(AdminScreen)
screens(AdminScreen)
}
}
}
}Dynamic Entry Points
Use start(route = { store -> ... }) instead of start(screen) when the start screen depends on runtime state (e.g. onboarding vs. dashboard).
graph("content") {
start(
route = { store ->
val state = store.selectState<ContentState>().first()
if (state.hasContent) DashboardScreen else EmptyScreen
}
)
screens(DashboardScreen, EmptyScreen)
}Navigating
Use the navigation { } DSL extension on StoreAccessor from within a coroutine scope.
// Navigate to a screen with its path params
store.navigation {
navigateTo(ProfileScreen, "userId" to "42")
}
// Navigate back
store.navigation {
navigateBack()
}
// Navigate and trim the back-stack
store.navigation {
navigateTo("main/dashboard")
popUpTo("home", inclusive = false)
}
// Navigate to a modal overlay
store.navigation {
navigateTo(SettingsModal)
}Deep Links
A deep link names a full path, such as workspace/invite/abc123, and lands on it with the screens above it in the graph built beneath it. Register aliases in deepLinkAliases { } for external URL shapes that differ from your routes. An alias pattern uses the same {name} placeholders as a route.
val navigationModule = createNavigationModule {
rootGraph { /* ... */ }
deepLinkAliases {
alias(
pattern = "artist/invite/{token}",
targetRoute = "workspace/invite/{token}"
)
}
}
// Handle an incoming URI (e.g. from Activity.onCreate or a push notification handler).
// Pass it in its encoded form, on Android Uri.encodedPath and Uri.encodedQuery.
store.navigation { navigateDeepLink("artist/invite/abc123") }Running in a Browser
A wasm app in a top-level browser window behaves the way people expect from a web page, with no extra wiring:
the address bar names the current screen, so any screen can be bookmarked or shared
every navigation adds a browser history entry, and the back and forward buttons move through them
a reload restores the exact stack, including params such as numbers and objects
a shared link opens the screen it names with its parent screens beneath it, while browser back returns to the page the visitor came from
URL styles
Clean paths (https://example.com/profile/7) are the default. The server has to answer every app path with the app's index.html, or a reload or a shared link gets a 404 from the server before the app can load. Most hosts need one rule for that:
# nginx
location / {
try_files $uri $uri/ /index.html;
}
# Netlify _redirects
/* /index.html 200
# Firebase hosting, firebase.json
"rewrites": [{ "source": "**", "destination": "/index.html" }]The Kotlin dev server started by wasmJsBrowserDevelopmentRun needs the same, through a file in the app module at webpack.config.d/devServer.js:
config.devServer = config.devServer || {};
config.devServer.historyApiFallback = true;The page also needs a <base href>, for example <base href="/">, so its scripts and wasm files load from nested paths and the app knows where it lives.
val navigationModule = createNavigationModule {
browserHistory {
basePath = "/app/"
documentTitle = { title -> if (title == null) "My app" else "$title - My app" }
}
rootGraph { /* ... */ }
}basePath defaults to the path of document.baseURI, so a <base href="/app/"> tag is usually enough on its own. documentTitle turns the current screen's titleResource into the page title, which is also what the browser shows in its history menu.
Hash URLs (https://example.com/#/profile/7) work on any static host, including hosts that cannot rewrite paths such as GitHub Pages, because the server only ever sees the page itself. The app then owns only the part after #, and the query string the page was opened with is left alone.
browserHistory {
urlStyle = UrlStyle.Hash
}Prefer paths when the app signs in through OAuth, since providers often refuse redirect addresses containing # and some return their result in the fragment, and when the same address should also open the Android or iOS app.
What goes into the address
Path params always appear in the path. Other params appear as query parameters when they are strings, numbers or booleans. Objects and lists never appear in the URL. They are kept in the browser history entry instead, so back, forward and reload restore them, but a copied link does not carry them. List keys in hiddenUrlParams to keep simple params out of the URL as well. Keys that look like secrets, such as token, password or secret, never reach the URL or the browser history entry.
With paths the whole query string belongs to the screen. A parameter the page was opened with, such as ?campaign=spring, becomes a param of the first screen and leaves the address on the next navigation.
Back that a screen refuses
A screen whose dismissal.back is Ignore or Run holds the first browser back. A Run handler runs, for example to ask whether to discard changes. Browsers do not let a page trap its visitors, so a second back without any click or key press in between goes through. A system layer alert on top of a screen is dismissed by browser back and the screen stays.
Guarded screens and sign in
A guard that answers PendAndRedirectTo keeps the pending navigation across a reload and across a sign in flow that leaves the page, such as an OAuth redirect. resumePendingNavigation() after the visitor returns lands where they were going. Param keys that look like secrets are not kept.
Things to know
Do not persist
NavigationStateyourself on the web. The browser history already restores it, and a stale persisted stack would compete with the address the visitor opened.Only one store per page drives the browser history. An app inside an iframe leaves the parent's history alone unless it sets
mode = BrowserHistoryMode.Always, andBrowserHistoryMode.Offturns the feature off entirely.Browsers never let a page delete history entries, so after going back the forward entries stay until the next navigation replaces them.
The app draws to a canvas, so there are no real links to open in a new tab.
Chrome may skip history entries that were added without any click or key press when the visitor presses back. Navigation in response to user input is never affected.
On touch devices the browser owns the screen edges, so an edge swipe goes back through the browser and the app's own swipe starts further in. A mouse drag never starts a back swipe.
Link Map
Every place the app can navigate to can be listed without reading the navigation DSL, for example when marketing asks which universal links or deep links exist.
val map: NavigationLinkMap = navigationModule.linkMap()
map.routes.forEach { route ->
println("${route.path} opens ${route.screen} (${route.access})")
}linkMap() returns a serializable description of the module:
routes: every registered path with its screen, its own route, its graph, its path params, the guards in front of it and whether a link can reach itgraphs: every graph with its path, its parent and how it startsaliases: every deep link alias with the route it leads towebPrefix: the browser address prefix when browser history is active
access tells whether a link can land on a route:
| Access | Meaning |
|---|---|
Linkable | A link opens this route |
Fallback | The not found screen, shown for links that match nothing |
Internal | System layer screens, the loading modal and the crash screen, which no link reaches |
Shadowed | Another route with the same shape wins, so links never land here |
App link files
iOS universal links and Android App Links need files on the website and in the app that list which paths open the app. appLinkFiles generates them from the map:
val files = navigationModule.linkMap().appLinkFiles(
AppLinksConfig(
host = "example.com",
appleAppIds = listOf("ABCDE12345.com.example.app"),
androidPackage = "com.example.app",
androidCertFingerprints = listOf("14:6D:E9:83:..."),
paths = setOf("home/leaderboard/player/*", "home/news/*"),
deepLinkScheme = "myapp"
)
)
File("site/.well-known/apple-app-site-association").writeText(files.appleAppSiteAssociation!!)
File("site/.well-known/assetlinks.json").writeText(files.assetLinks!!)
println(files.androidManifestIntentFilter)
println(files.androidDeepLinkIntentFilter)appleAppSiteAssociationlists every chosen path for each iOS app ID. It is generated when at least one app ID is given.assetLinksties the site to the Android package and its signing certificate fingerprints. WithandroidDynamicPaths(the default) it also lists the paths, which Android 15 and newer read as dynamic app links. It is generated when a package is given.androidManifestIntentFilteris the<intent-filter android:autoVerify="true">to add to the activity that handles the links, with one<data>entry per path.androidDeepLinkIntentFilteris a plain<intent-filter>for custom scheme deep links such asmyapp://example.com/home/news. It is generated whendeepLinkSchemeis given, usesdeepLinkHostor elsehost, and lists the paths without the web base path, since the app receives them as they are. Android does not verify custom schemes, so another app can claim the same one.Both filters are
nullwhen no path is chosen, because a filter without paths opens the app for every address on the host.
Candidate paths are every linkable route, every graph that has a start, and every deep link alias whose pattern is a web address or a plain path. Path params become * and the web base path (from browserHistory, or basePath) is put in front. paths picks which of them to include, by the path of an AppLinkPath, and null includes them all. files.candidates always lists every path, so a tool can offer the full choice.
Running this in a JVM test or a small script keeps the files in step with the navigation graph.
openLink opens a link the way a universal link is opened, and reports what happened:
when (val outcome = store.openLink("home/leaderboard/player/{playerId}", mapOf("playerId" to "42"))) {
is LinkOutcome.Landed -> println("landed on ${outcome.location}")
is LinkOutcome.Redirected -> println("a guard sent it to ${outcome.to}")
is LinkOutcome.MissingParams -> println("fill in ${outcome.names}")
else -> println(outcome)
}The link can be a route template, a concrete path, a path with a query, an alias address such as myapp://example.com/p/42 or a web address under the app's base path. Parent screens are built beneath it, guards run and the back stack is replaced.
The DevTools Nav tab draws the map and opens links on a connected device when the app installs NavigationLinks from reaktiv-navigation-tooling.
Interactive Gestures
NavigationRender ships a complete interactive gesture system with no wiring required:
Edge-swipe back — a horizontal drag from the screen edge scrubs the pop transition, iOS-style. On Android the system predictive-back gesture drives the same preview. The edge swipe wins over horizontally scrollable content, matching native iOS edge-pan.
Swipe to dismiss — vertically presented screens (e.g.
SlideUpBottom) and modals dismiss with a downward drag. Scrollable content hands off to the gesture when scrolled to the top; a default grabber indicator marks a dismiss zone at the top of the screen content that always dismisses, even over drag-consuming content such as pull-to-refresh.Premounted reveals — the screen beneath a dismissible sheet stays composed (modal and iOS sheet semantics), so dismiss gestures reveal it instantly and its state survives. Regular push/pop navigation keeps Compose semantics: the previous screen is disposed and its effects re-run when navigated back to.
Per-navigatable knobs (all with sensible defaults):
object FilterSheet : Screen {
override val route = "filters"
override val enterTransition = NavTransition.SlideUpBottom
override val exitTransition = NavTransition.SlideOutBottom
override val backGestureEnabled = true
override val swipeToDismiss = true
override val showsDismissIndicator = true
override val onDismissRequest: (suspend StoreAccessor.() -> Unit) = {
navigation { popUpTo("home") }
}
@Composable
override fun Content(params: Params) { FilterContent() }
}onDismissRequest is the unified dismiss funnel: edge-swipe commits, swipe-down commits, tap-outside on modals, and system back all route through it when set.
Reading Navigation State
Observe NavigationState to react to the current screen, back-stack depth, or open modals.
store.selectState<NavigationState>().collect { navState ->
println("Current screen: ${navState.currentEntry.navigatable.route}")
println("Back-stack depth: ${navState.backStack.size}")
println("Modal open: ${navState.activeModalContexts.isNotEmpty()}")
}Key Types
NavigationModule /
createNavigationModule— DSL entry pointNavigationLogic —
navigate,navigateBack,popUpTo,clearBackStack,navigateDeepLinkNavigationState — full navigation state (currentEntry, backStack, modals)
Screen — destination with route, transitions, and Composable content
Modal — overlay destination rendered above the current screen
NavigationGraph — hierarchical grouping of screens with a start destination
NavigationEntry — a resolved position in the back-stack (navigatable + params)
NavTransition — sealed class with 18 named transition variants plus
CustomandNoneModalContext — tracks an open modal and its underlying screen