JsonUI
← GuidesNavigation between screensNavigation in JsonUI is a platform-external contract: the spec declares intent (transitions + onNavigate method), the generator emits a typed callback stub in the Data interface, and every platform's router layer — Route enum, NavigationStack, NavHost, Next.js page.tsx — is hand-written. This guide shows the contract, what the generator actually emits (spoiler: not much), and how to wire the routers yourself on each platform.~16 min read
1. The contract (spec layer)Three fields in the spec together describe navigation. userActions[] names the human-facing action and its processing. transitions[] captures the routing graph: trigger, condition, destination, description. dataFlow.viewModel.methods[] declares onNavigate(url: String) as the universal funnel. All three are platform-agnostic — the `url` string is opaque to the spec and gets resolved to a concrete destination by each platform's router. There is no iOS-only or Android-only knob here.
screen_spec.json (excerpt)
"stateManagement": {
"eventHandlers": [
{ "name": "onSubmit", "description": "Validate + navigate to /thanks." }
]
},
"userActions": [
{ "action": "Tap submit button", "processing": "onSubmit() then onNavigate('/thanks')." }
],
"transitions": [
{ "trigger": "onNavigate(url)", "condition": "url === '/thanks'", "destination": "ThanksScreen" }
],
"dataFlow": {
"viewModel": {
"methods": [
{ "name": "onNavigate", "params": [{"name": "url", "type": "String"}],
"description": "Router.push(url). URL is opaque to spec." }
]
}
}
2. What the generator actually writesThis is the most commonly misunderstood part: `jui generate project` does NOT write navigation code. It writes three small things — a typed callback slot in the Data interface, a Route enum sketch under src/types/ (Web only), and a ViewModelBase that accepts a router instance via its constructor — and stops there. Route enums, NavigationStack configurations, NavHost definitions, URL-to-destination mappings: all hand-written.
what jui generate project writes
[Web] src/generated/data/<Screen>Data.ts ← onNavigate?: (url: string) => void slot
src/generated/viewmodels/<Screen>ViewModelBase.ts ← constructor takes router
[iOS] <Screen>View.swift (UIKit) / .swift (SwiftUI) ← layout shell, no Navigation
[Android] <Screen>Screen.kt (Compose) / Fragment.kt (XML) ← layout shell, no Navigation
 
(Route enum / NavHost / NavigationStack / router.push — all hand-written)
• Mental model: the generator hands you a typed hole (onNavigate?: (url: string) => void). You bring the router. Do not look for auto-generated routing tables — they do not exist.
3. Wiring the Web router (Next.js App Router)By default the Web side targets Next.js App Router — the Data interface's onNavigate slot is typed with the router from next/navigation. Pattern: read useRouter() in the page.tsx, pass it into the hand-written ViewModel, implement onNavigate as router.push(url). URL is opaque to the spec; file-based routing under src/app/ does the resolution. Since jsonui-cli 1.6.7 the router import, hook and type all come from the web_framework adapter in rjui.config.json, so a Remix or TanStack Start project declares its own router wiring there and this same pattern applies with that framework's hook.
src/viewmodels/CounterViewModel.ts
// src/viewmodels/CounterViewModel.ts — hand-written
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { CounterViewModelBase } from "@/generated/viewmodels/CounterViewModelBase";
 
export class CounterViewModel extends CounterViewModelBase {
constructor(private router: AppRouterInstance) { super(); }
 
protected initializeEventHandlers() {
this.updateData({
onNavigate: (url: string) => this.router.push(url),
onSubmit: () => this.router.push("/thanks"),
});
}
}
src/app/counter/page.tsx
// src/app/counter/page.tsx — hand-written
"use client";
import { useRouter } from "next/navigation";
import { useCounterViewModel } from "@/generated/hooks/useCounterViewModel";
import { CounterViewModel } from "@/viewmodels/CounterViewModel";
import CounterLayout from "@/Layouts/counter.json";
 
export default function CounterPage() {
const router = useRouter();
const { data } = useCounterViewModel(() => new CounterViewModel(router));
return <LayoutRenderer layout={CounterLayout} data={data} />;
}
4. Wiring iOS (SwiftUI NavigationStack)For SwiftUI, define a typed Route enum, hold the navigation path as @State, and branch on Route in .navigationDestination(for:). onNavigate(url) maps url strings into Route cases and appends to the path. The UIKit alternative is a UINavigationController with pushViewController calls — same idea, different API. Neither form is generated.
AppRouter.swift (hand-written)
// hand-written — AppRouter.swift
enum Route: Hashable {
case counter
case thanks
case settings
}
 
struct AppView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
CounterView(onNavigate: { url in
switch url {
case "/thanks": path.append(.thanks)
default: break
}
})
.navigationDestination(for: Route.self) { route in
switch route {
case .counter: CounterView(onNavigate: { _ in })
case .thanks: ThanksView()
case .settings: SettingsView()
}
}
}
}
}
5. Wiring Android (Compose Navigation)For Compose, define a sealed Route class, host screens in a NavHost with composable(Route.Foo.path) { … } entries, and wire onNavigate to navController.navigate(url). XML NavGraph + Fragment is the alternative — same concept using action IDs generated from navigation/*.xml. Safe Args plugin (optional) gives you type-safe argument passing in XML mode.
AppNav.kt (hand-written)
// hand-written — AppNav.kt
sealed class Route(val path: String) {
object Counter : Route("counter")
object Thanks : Route("thanks")
object Settings : Route("settings")
}
 
@Composable
fun AppNav() {
val nav = rememberNavController()
NavHost(nav, startDestination = Route.Counter.path) {
composable(Route.Counter.path) {
CounterScreen(onNavigate = { url -> nav.navigate(url.trimStart('/')) })
}
composable(Route.Thanks.path) { ThanksScreen() }
composable(Route.Settings.path) { SettingsScreen() }
}
}
6. Sheets, modals, tabs, backBeyond the basic push, each platform has its own idioms for non-stack navigation. These never show up in the spec explicitly — they are a function of how you resolve the `url` string in the router layer.• Sheet: Web uses an overlay React component or a dedicated /modal/* route. iOS uses .sheet(isPresented:) in SwiftUI or UIViewController.present(_:animated:) in UIKit. Android uses ModalBottomSheet in Compose or BottomSheetDialogFragment in XML.• Full modal: Web uses a dedicated route (eg /modal/confirm). iOS uses .fullScreenCover() in SwiftUI or modal presentation in UIKit. Android uses Dialog(onDismissRequest:) in Compose or DialogFragment.• Tabs: Web typically uses URL-based tabs (/section/tab-a, /section/tab-b). iOS uses SwiftUI TabView or UITabBarController. Android uses TabRow + HorizontalPager in Compose or TabLayout + ViewPager2 in XML.• Back: Web uses the browser back button or router.back(). iOS pops the NavigationStack automatically on back-swipe or can be driven via path manipulation. Android's NavController pops automatically on hardware/gesture back.
7. Navigation inside an EmbedA screen hosted inside an `Embed` navigates in one of two modes. With `navigationMode: "delegate"` (the default) the child does not own a private navigation stack — it shares the parent's. With `"isolated"` (SwiftJsonUI 10.5.0+ / KotlinJsonUI 2.12.0+) the embed owns a private nested stack. The mode shapes how `push`, `pop`, and gesture/back behave, with one invariant that holds in both: a child screen can never dismiss its own host.• `push` — delegate mode: the child's `navigate(url)` bubbles to the parent's NavController/Router and the new screen takes the entire window, same as if the parent had pushed it. Isolated mode: push stays inside the embed — the new screen appears within the embed's frame.• `pop` / `dismiss` / `navigateBack` — delegate mode: bounded at the embed; the framework absorbs the call so the embed itself is not torn down. Isolated mode: pop walks the embed's private stack and stops at its root — same invariant. Either way, dismissing the embedded screen is the parent's move: emit the relevant `events` handler or unmount the `Embed` node.• `navigationMode: "isolated"` — the embed owns a private nav stack (nested NavigationStack / NavHost / memory router). Present-type transitions (sheet / dialog / dismiss) are forbidden inside the embedded screen's spec — validation rejects them. OS gestures and browser back are platform-delegated: iOS edge swipe pops the outermost stack, Android back pops the deepest non-empty embed stack first, browser back walks only the host history — don't rely on gestures for in-embed pops. Escape hatch for navigating out of the embed (logout, session expiry): pass a parent-VM callback down via `params`; the parent always executes the transition.
Keep reading
Writing your first specThe foundation this guide builds on./guides/writing-your-first-spec
Developer menuWrap your iOS / Android app root in DeveloperMenuContainer to jump between screens and hot-reload the JSON you just authored./guides/developer-menu