← 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.• 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.
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)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-writtenimport 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.swiftenum 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.ktsealed class Route(val path: String) { object Counter : Route("counter") object Thanks : Route("thanks") object Settings : Route("settings")} @Composablefun 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
© 2026 JsonUI contributors. Built by the same toolchain documented here.