Routing
Most apps are more than one page — a home screen, a profile, a settings panel. Routing is showing the right one for the current URL and switching between them without a full page reload. Ramonda's router is a separate package:
pnpm add @ramonda/router
You write a route table with createRoutes, then createRouter hands you the pieces bound
to it: Router (tracks the URL), RouteOutlet (shows the matching page), Link and
Navigator (below), and route (for :param links).
import { Component } from "@ramonda/core";
import { createRoutes, createRouter } from "@ramonda/router";
export const routes = createRoutes({
"/": <Home />,
"/players/:id": <Player />,
"*": <NotFound />,
});
export const { Router, RouteOutlet, Navigator, Link, route } = createRouter(routes);
export class App extends Component {
router = this.use(Router);
render() {
return (
<div className="app">
<NavBar />
<RouteOutlet routes={routes} />
</div>
);
}
}
Put this in one module — routes.ts in the examples that follow — and import Link / Navigator /
route / routes from it across your app. Call createRouter once, there: it is the only
place Link and Navigator come from, and binding them to your table is what makes links
type-safe — next.
pathname/routing
searchParams(none)
→ State→ Lists→ back here, with a query
Reads are per key: a component reading only pathname is not re-rendered when the query changes.
(That demo reads the router of this site — these docs are a Ramonda app, so the values are real.)
The routes table
createRoutes maps each URL pattern to what to show:
"/"— the home page."/players/:id"— a pattern;:idmatches any value, and the page can read it (see params)."*"— the fallback for when nothing else matches (a "not found" page).
Call it once, at the top of a module — not inside render() — so the patterns
are compiled a single time.
Router is a hook; RouteOutlet is where the page goes
You add Router with this.use(Router) on your top component: it tracks the URL and
adds no element of its own. RouteOutlet is the component you place where the routed
page should appear.
Keeping the two separate is what lets a nav bar sit beside the outlet and stay put as you move around — only the outlet's content swaps, everything around it keeps its state:
<div>
<NavBar /> {/* stays as you navigate */}
<RouteOutlet routes={routes} /> {/* this is what changes */}
</div>
Links are type-checked against your routes
Because createRouter knows your table, <Link href> only accepts a real path — change or
rename a route and every stale link becomes a compile error, not a broken link you find by
clicking:
import { Link, route } from "./routes";
<Link href="/">Home</Link> // ✓
<Link href="/nope">Home</Link> // ✗ not a route
<Link href={route("/players/:id", { id: "9" })}>…</Link> // ✓ params typed; a missing/misspelled param errors
A static path goes in directly; a :param path must be built with route(...), which fills the
params and rejects a wrong one. The same paths type Navigator.push / replace. More in
links.
The types the kit is made of — TypedRouterKit
createRouter(routes) returns a TypedRouterKit, and every name below exists so that the route
table's paths reach a type annotation.
RouteConfig | what createRoutes returns — the compiled table, carrying its literal path keys |
PathOf<C> | the navigable paths of a config: every declared key except the "*" fallback |
TypedLinkProps | Link's props, with href narrowed to those paths |
TypedNavigator | the Navigator hook's type, with push and replace narrowed the same way |
Href | a path that route(...) has built and checked — a string the kit will accept |
RouteParams | the :param values a matched route supplies, as a record of strings |
RouteOutletProps | RouteOutlet's props |
RouteConfig carries its paths in a phantom field. Nothing at runtime holds them: they exist
only so createRoutes({ "/": …, "/u/:id": … }) remembers the literal union "/" | "/u/:id", and
createRouter can type a Link against it. The field defaults to string, which is what lets a
function taking a plain RouteConfig still accept a narrower one.
RouterNavigator is the untyped shape underneath TypedNavigator — the methods themselves, before
the paths are narrowed. You would name it only when writing something that takes any router's
navigator rather than this one's.
One Router per app
Mounting a second Router while one is live throws — there is a single source of
truth for the URL, and two would disagree. (Unmounting one and mounting another is
fine, so tests and hot reload work.)
Why route through it at all
You could set window.location yourself. What the router buys you is that every
change to the URL goes through one channel, and each one starts from the freshest
state — even the imperative push and a <Link> click share it. So two navigations
that land in the same tick serialize instead of clobbering each other: no lost update
where one write reads stale state and overwrites the other. URL bugs that only show up
under fast clicks or async races simply don't have a place to happen.
The other half is that the URL is a place to keep state. A selected tab, an open filter, a search query — put it in the URL and it survives a reload, it's shareable as a link, and Back undoes it. See keeping state in the URL.
Next
- Links — moving around without breaking what a real link does.