---
title: "Routing | zenit Zig UI Docs"
description: "Describe pages as an enum, expose the current page and its params as two Signals, and get back / forward from a fixed-capacity history stack."
url: https://ui.surf/zenit/docs/advanced/routing
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/advanced/routing.md
alternate_es: https://ui.surf/zenit/es/docs/advanced/routing.md
alternate_ja: https://ui.surf/zenit/ja/docs/advanced/routing.md
alternate_ko: https://ui.surf/zenit/ko/docs/advanced/routing.md
alternate_fr: https://ui.surf/zenit/fr/docs/advanced/routing.md
alternate_de: https://ui.surf/zenit/de/docs/advanced/routing.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# In-app routing

Describe pages as an enum, expose the current page and its params as two Signals, and get back / forward from a fixed-capacity history stack.

## The Router model

`ui.fx.Router(Page)` is an enum-driven, in-memory router, not a web URL router. It exposes two Signals, `current: *Signal(Page)` and `params: *Signal(RouteParams)`, and keeps a history stack of up to 64 entries.

`app.zig`

```zig
const std = @import("std");
const ui = @import("ui");

const Page = enum { welcome, editor, settings };
const Router = ui.fx.Router(Page);

fn mountApp(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    // The router's two Signals are created in `scope`, so pass a scope that
    // lives as long as the window. The router value itself is large (~290 KB of
    // inline history), so keep it in Cx-owned state and pass the pointer around.
    const router = try cx.bindState(Router, try Router.init(scope, .welcome));

    const shell = try ui.box(cx, .{
        .width = .fill(),
        .height = .fill(),
        .direction = .column,
    }, .{});
    // ...append a toolbar and the page host (see "Rendering pages")
    _ = router;
    return shell;
}
```

> NOTE
> 
> **The router is a large value.** History is an inline array: 64 entries, each carrying a `RouteParams` with up to 8 params in inline buffers (64-byte key + 512-byte value), about 290 KB in total. Keep it in long-lived storage such as `cx.bindState` and pass a pointer; don’t copy it by value.

## Navigation and params

`navigate.zig`

```zig
var params: ui.fx.router.RouteParams = .{};
try params.set("file", "draft.md"); // copies the bytes; errors on overflow

router.navigate(.editor, params); // page + params
router.navigateTo(.settings); // page, empty params

_ = router.back(); // false when already at the oldest entry
_ = router.forward(); // false when there is no forward branch

if (router.canGoBack()) {
    // e.g. enable a Back button
}
```

`RouteParams.set` copies the key and value bytes into its own buffers, so later changes to the caller’s buffer don’t affect it; setting an existing key overwrites the value. Capacity is fixed, and overflow returns an error instead of truncating:

| Limit | Value | On overflow |
| --- | --- | --- |
| Params per entry | `max_params = 8` | `error.TooManyParams` |
| Key length | `max_key_len = 64` | `error.KeyTooLong` |
| Value length | `max_value_len = 512` | `error.ValueTooLong` |

`navigate`, `back` and `forward` all write both Signals. `canGoBack()` / `canGoForward()` are plain reads, not Signals. To drive the router from a handler, keep the pointer in a `cx.bindState` struct:

`back_button.zig`

```zig
const Nav = struct {
    router: *Router,

    fn goBack(self: *Nav) void {
        _ = self.router.back();
    }
};

const nav = try cx.bindState(Nav, .{ .router = router });
const back_button = try ui.widgets.Button(.{
    .label = "Back",
    .on_click = cx.on(Nav, nav, Nav.goBack),
}).mount(scope, cx);
```

## Rendering pages

The current page is a `Signal(Page)`; switch page subtrees on it with `ui.Match`. Match gives each page a child Scope; on a switch it disposes the old Scope first (its Signals, Effects and resources go with it), then frees the old nodes and mounts the new page.

`pages.zig`

```zig
const page_host = try ui.box(cx, .{
    .width = .fill(),
    .height = .fill(),
}, .{});

// Match appends the page to page_host and rebuilds it when router.current
// changes; each page gets a fresh child Scope that is disposed on leave.
_ = try ui.Match(Page, scope, page_host, router.current, cx, struct {
    fn build(child_scope: *ui.Scope, c: *ui.Cx, page: Page) anyerror!*ui.Node {
        return switch (page) {
            .welcome => mountWelcome(c, child_scope),
            .editor => mountEditor(c, child_scope),
            .settings => mountSettings(c, child_scope),
        };
    }
}.build);
```

Match rebuilds only when the enum value changes: navigating from `.editor` to `.editor` with different params keeps the page mounted. A page that depends on params should subscribe to `router.params` itself:

`editor_page.zig`

```zig
// editor -> editor with other params does not rebuild the page,
// so subscribe to the params Signal inside the page.
try scope.createEffect(.{ .params = router.params }, struct {
    fn run(ctx: anytype) void {
        // get() returns a copy; keep it in a local so the slices stay valid.
        const params = ctx.params.get();
        const file = params.get("file") orelse "untitled.md";
        std.log.info("editor shows {s}", .{file});
    }
}.run);
```

## History rules

HISTORY STACK

back / forward only move the cursor; a navigate after back first cuts history\_len at the cursor, then pushes the new entry.

-   **New navigation truncates the forward branch.** Navigating after back discards the old forward entries.
    
-   **Every navigate pushes.** The same page with different params (or even an identical target) becomes a new entry.
    
-   **At 64 entries, the older half goes.** Before the 65th push the newest 32 are kept and the cursor shifts with them.
    
-   **No URLs.** Deep links and system URL schemes must be mapped to `navigate` by the app.
    

CAPACITY 64

The drop is batched: instead of evicting one entry per push, it frees half the capacity at once.

## Tracing navigation

The router doesn’t log on its own. To debug navigation, write the relevant transitions to the current Cx’s Console under a `router` scope; DevTools Console can then filter by scope and show the level and call site.

`trace.zig`

```zig
router.navigate(.editor, params);
cx.console().scoped("router").debug("navigate -> {s}", .{@tagName(Page.editor)});
```

See [DevTools](https://ui.surf/zenit/docs/advanced/devtools) for the full Console API.

> WARNING
> 
> **Two different routers.** This docs site uses a browser path router; a Zenit desktop app’s `ui.fx.Router` works on enums and Signals and has no notion of URLs.
