Skip to main content

Routing

Routes come from files. Everything about them except the current path is decided at compile time.

The route table

done

Files under windows/<name>/pages/** become routes:

FileRoute
pages/index.tsx/
pages/layout.tsxlayout
pages/products.tsxproducts
pages/products/new.tsxproducts/new
pages/products/$id.tsxproducts/$id

index names the directory it sits in, so pages/products/index.tsx also produces products — and that collision is reported as a duplicate rather than resolved by a rule.

A $ prefix makes a segment a parameter. Static segments beat parameters at the same depth, so products/new matches before products/$id.

bun run routes # print the table

useRouter

done

Read access to the window's current route.

const router = useRouter();

<div>You are at {router.path}</div>;

router.path

done

The active route, as an ordinary signal — the window's own, by identity. Interpolating it is a text binding that follows navigation, and anything derived from it is a computed like any other.

router.matches

done
<button className={cn("link", { active: router.matches("layout") })}>Layout</button>

True while the active route is path, or nested under it. Prefix-aware: matches("products") holds on products/new too, because a nav entry names a section.

:::danger Do not use ===

// Wrong. Always false, for ever.
router.path === "layout";

router.path is a signal, so comparing it to a string is false at build time and the nav compiles clean and never highlights. Use matches, or — for exact equality — compare in the window's own module where the route signal lives:

export const onNewProduct = computed(() => route === "products/new");

That works because the reactive rewrite runs on your module. :::

useRouter() is read-only, deliberately. Anything derived belongs in the window's own module as a computed, because a computed() created inside a component has no export name for the generated module to import.

Calling it in a window that did not pass route to <Window> is an error naming the fix.

useRoute

done
export default function ProductDetail() {
const route = useRoute("products/$id");
// ...
}

The string must match the file's own path under pages/, because it is what types args and nothing else verifies it. A mismatch is an error naming both paths — it means the file moved and the string did not.

:::warning Params are not bindings yet done

The recorders exist but the emitter does not read them, so args.id does not yet produce a live binding. Track it in API.md. :::

Href

done

Each window gets an Href union generated from its route table, so a typo in a static segment is a type error.

A static route contributes a string literal; a parameter contributes ${string}, so both "products/1" and a template literal check.

:::note A limit worth knowing before it surprises you

${string} spans slashes, so the type also accepts products/a/b/c. TypeScript catches typos; the compiler catches shape.

Worse: a parameter in the first segment — pages/$slug.tsx — contributes a bare ${string}, which absorbs every other member and makes that window's Href equal to string. The type then checks nothing. :::

done(2026-08-20) — `src/runtime/navigate.ts`, exported from `dziry`. The host installs the window's route signal at launch; `navigate(path)` writes it (same-path early-out), `back()` reads the one-entry history — one entry by decision, so a second `back()` oscillates. `navigate("…")` literals in captured handler sources are checked against the route table like hrefs (`deadNavigations` in build.ts); module-level handler bodies cross the boundary as names and are the `Href` type's to check. Before the window is up, `navigate()` warns and does nothing — modules are also imported by the compiler

There is no navigate() yet. Today a window exports one handler per destination:

export const route = signal("/");

let previous = "/";

function go(path: string): void {
if (path === route) return;
previous = route;
route.set(path);
}

export const goLayout = () => go("layout");
export const back = () => go(previous);

That is not boilerplate to apologise for: a click handler has to be a module-level export, because the artifact imports it by name, and onClick={() => go("layout")} at a call site would be a closure with nowhere to live.

navigate needs the matcher and a way to pass an argument to a compiled handler. Until then the repetition is visible rather than hidden behind something that does not work. History is currently one entry deep, by decision.

Types

import type { Args, Route, Router } from "dziry";

Args<P> maps a path's parameter names to strings — Args<"products/$id"> is { id: string }.