@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/router

Example#

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#

MemberWhat 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).

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" });
This very site uses the router. A hard refresh of a deep link needs a server fallback — see Deployment.

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 createRouter and view() work without a browser. Pass the request URL as { location } and the server renders that route; use match() to load its data first. The client takes over on hydration.