HypenHypen
Guide

Routing

Navigation and routing in Hypen applications

Routing

Hypen uses a declarative routing model. You define your routes in the DSL, and your module manages the navigation state. This approach works across all platforms — the same route definitions render via browser history on web, native navigation stacks on mobile, or state-driven routing in remote UI.

Basic Setup

A route table is declared with Router and Route components:

module App {
    Router(initialRoute: "/home") {
        Route(path: "/home") {
            HomePage()
        }
        Route(path: "/about") {
            AboutPage()
        }
        Route(path: "/settings") {
            SettingsPage()
        }
    }
}

The module manages which route is active via state.location:

import { app } from "@hypen-space/core";

export default app
    .defineState({
        location: "/home",
        params: {},
    })
    .onAction<{ to: string }>("navigate", ({ state, action }) => {
        state.location = action.payload!.to;
    })
    .build();

Trigger navigation from buttons or links using actions:

// Simple navigation
Button { Text("Go to Settings") }
    .onClick(@actions.navigate, { to: "/settings" })

// Navigation bar
Row {
    Button { Text("Home") }
        .onClick(@actions.navigate, { to: "/home" })
        .color("${state.location == '/home' ? '#3B82F6' : '#6B7280'}")

    Button { Text("About") }
        .onClick(@actions.navigate, { to: "/about" })
        .color("${state.location == '/about' ? '#3B82F6' : '#6B7280'}")

    Button { Text("Settings") }
        .onClick(@actions.navigate, { to: "/settings" })
        .color("${state.location == '/settings' ? '#3B82F6' : '#6B7280'}")
}
.gap(16)
.padding(16)

Route Parameters

Use :param in the path pattern to capture dynamic segments:

Router {
    Route(path: "/users/:id") {
        UserProfile(userId: ${state.params.id})
    }
    Route(path: "/posts/:postId/comments/:commentId") {
        CommentView(
            postId: ${state.params.postId},
            commentId: ${state.params.commentId}
        )
    }
}

In your module, handle navigation that includes parameters:

.onAction<{ userId: string }>("viewUser", ({ state, action }) => {
    state.location = `/users/${action.payload!.userId}`;
    state.params = { id: action.payload!.userId };
})

Route Guards

Guards run before a route is entered. They can allow, redirect, or block navigation:

Route(path: "/admin", guard: @actions.requireAdmin) {
    AdminDashboard()
}

Route(path: "/account", guard: @actions.requireAuth) {
    AccountPage()
}
.onAction("requireAuth", async ({ state }) => {
    const isLoggedIn = await checkSession();
    if (!isLoggedIn) {
        state.location = "/login";
    }
})
.onAction("requireAdmin", async ({ state }) => {
    const user = await getUser();
    if (!user?.isAdmin) {
        state.location = "/home";
    }
})

Fallback Route

Handle unknown paths with Route.fallback:

Router(initialRoute: "/home") {
    Route(path: "/home") { HomePage() }
    Route(path: "/about") { AboutPage() }

    Route.fallback {
        Column {
            Text("404")
                .fontSize(64)
                .fontWeight("bold")
            Text("Page not found")
                .color("#6B7280")
            Button { Text("Go Home") }
                .onClick(@actions.navigate, { to: "/home" })
        }
        .horizontalAlignment("center")
        .verticalAlignment("center")
        .flex(1)
    }
}

Nested Routing

Routes can contain child routers for tabbed or sectioned layouts:

Route(path: "/settings") {
    Column {
        // Tab bar
        Row {
            Button { Text("Profile") }
                .onClick(@actions.navigate, { to: "/settings/profile" })
            Button { Text("Security") }
                .onClick(@actions.navigate, { to: "/settings/security" })
            Button { Text("Notifications") }
                .onClick(@actions.navigate, { to: "/settings/notifications" })
        }
        .gap(8)
        .padding(16)
        .borderBottom("1px solid #E5E7EB")

        // Nested route content
        Router {
            Route(path: "/settings/profile") {
                ProfileSettings()
            }
            Route(path: "/settings/security") {
                SecuritySettings()
            }
            Route(path: "/settings/notifications") {
                NotificationSettings()
            }
        }
    }
}

Complete Example

Here's a full app with a navigation bar, multiple routes, and parameterized routes:

module App {
    Column {
        // Navigation bar
        Row {
            Text("MyApp")
                .fontSize(20)
                .fontWeight("bold")
                .color("#111827")

            Spacer()

            Row {
                Button { Text("Home") }
                    .onClick(@actions.navigate, { to: "/" })

                Button { Text("Users") }
                    .onClick(@actions.navigate, { to: "/users" })

                Button { Text("Settings") }
                    .onClick(@actions.navigate, { to: "/settings" })
            }
            .gap(12)
        }
        .padding(16, 24)
        .horizontalAlignment("center")
        .borderBottom("1px solid #E5E7EB")

        // Route content
        Router(initialRoute: "/") {
            Route(path: "/") {
                Center {
                    Text("Welcome Home")
                        .fontSize(32)
                }
                .flex(1)
            }

            Route(path: "/users") {
                Column {
                    Text("Users")
                        .fontSize(24)
                        .fontWeight("bold")
                        .padding(24)

                    ForEach(items: @state.users, key: "id") {
                        Button {
                            Row {
                                Avatar(@item.avatar)
                                    .size(40)
                                Text(@item.name)
                                    .fontSize(16)
                            }
                            .gap(12)
                            .verticalAlignment("center")
                        }
                        .onClick(@actions.viewUser, { userId: @item.id })
                        .padding(12, 24)
                    }
                }
            }

            Route(path: "/users/:id") {
                UserProfile(userId: ${state.params.id})
            }

            Route(path: "/settings", guard: @actions.requireAuth) {
                SettingsPage()
            }
        }
    }
    .fillMaxSize(true)
}
import { app } from "@hypen-space/core";

export default app
    .defineState({
        location: "/",
        params: {},
        users: [
            { id: "1", name: "Alice", avatar: "/alice.jpg" },
            { id: "2", name: "Bob", avatar: "/bob.jpg" },
        ],
    })
    .onAction<{ to: string }>("navigate", ({ state, action }) => {
        state.location = action.payload!.to;
        state.params = {};
    })
    .onAction<{ userId: string }>("viewUser", ({ state, action }) => {
        state.location = `/users/${action.payload!.userId}`;
        state.params = { id: action.payload!.userId };
    })
    .onAction("requireAuth", async ({ state }) => {
        // Check authentication before entering route
        const session = await checkSession();
        if (!session) state.location = "/login";
    })
    .build();

How It Works

Under the hood, Router and Route are passthrough components in the engine. This means:

  • The engine preserves their props (path, guard, etc.) and expands their children
  • No template transformation happens on Router/Route themselves
  • The platform renderer reads state.location, matches it against Route paths, and shows the matching route's children

This design keeps the engine platform-agnostic. The same route definitions work with:

  • Web: Browser history.pushState
  • iOS: UINavigationController
  • Android: Jetpack Navigation
  • Remote UI: Server-side state changes

Next Steps