@zoijs/router
A tiny client-side router for single-page apps. Routes are a plain object; links are plain anchors.
npm install @zoijs/core @zoijs/routerExample#
import { html, mount } from "@zoijs/core";
import { createRouter } from "@zoijs/router";
const router = createRouter({
"/": Home,
"/about": About,
"/users/:id": (params) => html`<h1>User ${params.id}</h1>`,
"*": () => html`<h1>Not Found</h1>`,
});
function App() {
return html`
<nav>${router.link("/", "Home")} ${router.link("/about", "About")}</nav>
${router.view()}
`;
}
mount(App, "#app");A route is pattern: component. A component is a function that returns an html template and receives the matched params.
API#
| Member | What it does |
|---|---|
createRouter(routes, { base, decodeSlash }) | Build a router from a { pattern: component } map |
router.view() | Render the current page — place it once |
router.link(path, content) | An <a> that navigates without a reload; content is text or a template |
router.href(path), router.onLinkClick(path), router.isActive(path, { exact }) | Build your own <a> with classes, attributes and handlers (0.7.0+) |
router.go(path, { replace }) | Navigate from code — app paths only ("/x", "?q", "#h"); an absolute URL or scheme throws a clear TypeError. { replace: true } replaces the current history entry (0.7.0+) |
router.path() | The current path (reactive) — doesn't wake readers on query- or hash-only navigation |
router.query() | The query string as an object (reactive; an unchanged query keeps the same object) |
router.match(path?) | Resolve a path to { component, params } without rendering (for routed SSR) |
createRouter(routes, { location }) | Server-only: render the request's route under @zoijs/ssr |
Patterns support static segments, dynamic :params, and a "*" not-found route. Static routes win over param routes. The active link gets aria-current="page" automatically. A component may return null to render nothing — as does an unmatched URL with no "*" route (0.7.0+; earlier versions threw).
Your own links#
html can't spread attributes, so when a link needs its own class, data-/ARIA attributes or an extra handler, build the <a> from the same three pieces link() uses (0.7.0+):
html`<a class="nav-item" href=${router.href("/reports")}
onclick=${router.onLinkClick("/reports")}
aria-current=${() => (router.isActive("/reports", { exact: false }) ? "page" : null)}
><span class="icon" aria-hidden="true">📈</span> Reports</a>`onLinkClick leaves modified and middle clicks, target (other than _self), download, already-handled events and external URLs to the browser. isActive is exact by default (a query in the target must match too); { exact: false } also matches sub-paths.
Replace instead of push#
router.go(path, { replace: true }) swaps the current history entry for the new URL — for redirects and for URL state that changes often (search-as-you-type, filters), so Back skips the intermediate states. It scrolls only to a #hash target, or to the top when the page changed (0.7.0+).
if (!signedIn()) router.go("/login", { replace: true });Route params are data#
Params come from the URL, so treat them as untrusted input. They're decoded per segment, so by default an encoded slash becomes a real one (/files/..%2Fadmin → "../admin"). When params feed paths, API URLs or identifiers, keep slashes encoded:
const router = createRouter(routes, { decodeSlash: false });
// "/files/..%2Fadmin" → params.name === "..%2Fadmin" (other escapes still decode)Validate and encode params before using them in a path, URL or permission check, and authorize on the server — see Production Security.
Leaving the app#
router.go() navigates within the app, so it only takes app paths. To go to another site, use a plain <a href> or location.assign(url).
Hosting under a sub-path#
If your app lives under a sub-path, pass a base so route patterns stay clean:
createRouter(routes, { base: "/app" });Known limitations#
- History mode only (deep-link refreshes need a server SPA fallback).
- No nested-outlet system, route guards, or automatic data loaders — by design.
- SSR-safe and routed: under @zoijs/ssr
createRouterandview()work without a browser. Pass the request URL as{ location }and the server renders that route; usematch()to load its data first. The client takes over on hydration.