Skip to content
Whisq v0.1.0-alpha.9

Router basics

A minimal routed app that exercises the parts of @whisq/router you reach for on day one: dynamic params, nested routes, a beforeEach guard for auth-gating, and reactive active-link styling via router.current.

  • Public routes: / (home), /users, /users/:id, /users/:id/edit
  • A protected /admin route gated by a beforeEach guard
  • Nested routes under /users — a shared layout with its own sub-nav
  • A nav with active-link styling that highlights the current top-level route
  • A wildcard * fallback
Terminal window
npm install @whisq/core @whisq/router
src/router.ts
import { createRouter } from "@whisq/router";
import { Home } from "./routes/Home";
import { UsersLayout } from "./routes/UsersLayout";
import { UsersList } from "./routes/UsersList";
import { UserDetail } from "./routes/UserDetail";
import { UserEdit } from "./routes/UserEdit";
import { Admin } from "./routes/Admin";
import { Login } from "./routes/Login";
import { NotFound } from "./routes/NotFound";
import { isLoggedIn } from "./stores/session";
export const router = createRouter({
routes: [
{ path: "/", component: Home },
{ path: "/login", component: Login },
{
path: "/users",
component: UsersLayout,
children: [
{ path: "", component: UsersList }, // /users
{ path: ":id", component: UserDetail }, // /users/:id
{ path: ":id/edit", component: UserEdit }, // /users/:id/edit
],
},
{
path: "/admin",
component: Admin,
meta: { requiresAuth: true },
},
{ path: "*", component: NotFound },
],
beforeEach: (to) => {
// Guard: gate any route with meta.requiresAuth.
// Return a string to redirect; return false to cancel; return void/true to proceed.
if (to.meta.requiresAuth && !isLoggedIn()) {
return "/login";
}
},
scrollBehavior: "restore",
});
src/App.ts
import { component, div, header, nav, main } from "@whisq/core";
import { Link, RouterView } from "@whisq/router";
import { router } from "./router";
const isActive = (prefix: string) => () =>
router.current.value.path.startsWith(prefix) ? "active" : "";
export const App = component(() =>
div(
header(
nav(
Link({ href: "/", router, class: () => router.current.value.path === "/" ? "active" : "" }, "Home"),
Link({ href: "/users", router, class: isActive("/users") }, "Users"),
Link({ href: "/admin", router, class: isActive("/admin") }, "Admin"),
),
),
main(RouterView(router)),
),
);
src/main.ts
import { mount } from "@whisq/core";
import { App } from "./App";
mount(App({}), document.getElementById("app")!);
src/routes/UsersLayout.ts
import { component, div, h2, nav } from "@whisq/core";
import { Link, RouterView } from "@whisq/router";
import { router } from "../router";
export const UsersLayout = component(() =>
div(
h2("Users"),
nav(
Link({ href: "/users", router }, "All"),
Link({ href: "/users/1", router }, "Alice"),
Link({ href: "/users/1/edit", router }, "Edit Alice"),
),
// depth 1 — renders UsersList / UserDetail / UserEdit based on child match.
RouterView(router, 1),
),
);

Each child route gets its own component; RouterView(router, 1) swaps between them as the child path changes.

src/routes/UserDetail.ts
// Snapshot pattern: `id` comes from the render callback's params argument.
// RouterView re-mounts this component on every route change, so `id` is
// always fresh for the current match — no reactive wrapper needed.
import { h1, p, div } from "@whisq/core";
import { router } from "../router";
export const UserDetail = ({ id }: Record<string, string>) =>
div(
h1(`User ${id}`),
// router.current.value.path IS reactive across component re-mounts —
// useful when the same component instance survives multiple matches.
p(() => `Path: ${router.current.value.path}`),
);
src/routes/UserEdit.ts
// Reactive pattern: read the current id from router.current inside a getter.
// Equivalent to the snapshot form above when RouterView re-mounts per match,
// but explicit about the reactive source if you're extending this later.
import { h1, p, div } from "@whisq/core";
import { router } from "../router";
export const UserEdit = () =>
div(
h1(() => `Editing user ${router.current.value.params.id}`),
p("(imagine a form here)"),
);
src/stores/session.ts
import { signal } from "@whisq/core";
const loggedIn = signal(false);
export const isLoggedIn = () => loggedIn.peek();
export const login = () => { loggedIn.value = true; };
export const logout = () => { loggedIn.value = false; };

The beforeEach guard calls isLoggedIn() at navigation time — .peek() is used so the guard doesn’t subscribe the navigation to the signal. The login route flips the signal, then programmatically navigates:

src/routes/Login.ts
import { component, div, h1, p, button } from "@whisq/core";
import { router } from "../router";
import { login } from "../stores/session";
export const Login = component(() =>
div(
h1("Login"),
p("Pretend this is a form."),
button({
onclick: () => {
login();
router.navigate("/admin");
},
}, "Log in and go to /admin"),
),
);
src/routes/Home.ts
import { div, h1 } from "@whisq/core";
export const Home = () => div(h1("Home"));
// src/routes/Admin.ts
import { component, div, h1, button } from "@whisq/core";
import { router } from "../router";
import { logout } from "../stores/session";
export const Admin = component(() =>
div(
h1("Admin"),
button({ onclick: () => { logout(); router.navigate("/"); } }, "Log out"),
),
);
// src/routes/UsersList.ts
import { div, ul, li } from "@whisq/core";
import { Link } from "@whisq/router";
import { router } from "../router";
export const UsersList = () =>
div(
ul(
li(Link({ href: "/users/1", router }, "Alice")),
li(Link({ href: "/users/2", router }, "Bob")),
),
);
// src/routes/NotFound.ts
import { div, h1, p } from "@whisq/core";
export const NotFound = () => div(h1("Not found"), p(() => "404"));
  • Single router.current signal. Every reactive router read — RouterView’s mounted component, Link’s active class, any component reading params / query — ultimately subscribes to this one signal.
  • Nested RouterView(router, depth). Matching nests under children; RouterView at depth = 1 renders the matched child. Further nesting can go depth = 2, etc.
  • Guard return values. beforeEach returning a string redirects; returning false cancels; returning void (or true) proceeds. The guard is synchronous — for async auth checks, read a cached signal (via .peek()) instead of awaiting.
  • meta is how guards find routes. Setting meta: { requiresAuth: true } lets a single beforeEach handle every protected route without enumerating paths.
  • Active links. No dedicated active prop — use a reactive class getter reading router.current.value.path. Exact match with ===, prefix match with .startsWith().

Docs current to v0.1.0-alpha.9 . All releases →