@zoijs/router
v0.3.0
Published
A tiny, beginner-friendly router for Zoijs single-page apps. No JSX, no build step, no Virtual DOM.
Maintainers
Readme
@zoijs/router
A tiny router for Zoijs. Routes are a plain object, links are plain anchors, and there's no build step.
@zoijs/router is an optional package. The Zoijs core has no router — add this
only if your app needs one. It builds entirely on the core's public API, so the
core stays small and unchanged.
You can learn the whole thing in about 10 minutes.
Install
npm install @zoijs/core @zoijs/routerOr with no install, straight from a CDN:
import { html, mount } from "https://esm.sh/@zoijs/core@1";
import { createRouter } from "https://esm.sh/@zoijs/[email protected]";The whole idea
A route is pattern: component. A component is a function that returns an
html template and receives the matched params as a plain object.
import { html, mount } from "@zoijs/core";
import { createRouter } from "@zoijs/router";
function Home() {
return html`<h1>Home</h1>`;
}
function UserPage(params) {
return html`<h1>User ${params.id}</h1>`;
}
const router = createRouter({
"/": Home,
"/about": () => html`<h1>About</h1>`,
"/users/:id": UserPage,
"*": () => html`<h1>Not Found</h1>`,
});
function App() {
return html`
<nav>
${router.link("/", "Home")}
${router.link("/about", "About")}
</nav>
${router.view()}
`;
}
mount(App, "#app");That's a complete app. router.view() renders whichever component matches the
current URL and swaps it when you navigate.
The API (six functions)
| Method | What it does |
|---|---|
| createRouter(routes) | Build a router from a { pattern: component } map |
| router.view() | Reactive content for the current page — place it once |
| router.link(path, text) | An <a> that navigates without a page reload |
| router.go(path) | Navigate from code |
| router.path() | The current path, e.g. "/users/42" (reactive) |
| router.query() | The query string as a plain object (reactive) |
| router.match(path?) | Resolve a path to { component, params } without rendering (for routed SSR) |
| createRouter(routes, { location }) | Server-only: render the request's route (see Routed SSR) |
There's also router.destroy() to remove the back/forward listener — but you
rarely call it: the router cleans itself up when the app unmounts.
Creating routes
Patterns are matched segment by segment:
| Pattern | Matches | params |
|---|---|---|
| / | / | {} |
| /about | /about | {} |
| /users/:id | /users/42 | { id: "42" } |
| /posts/:year/:slug | /posts/2026/hello | { year: "2026", slug: "hello" } |
| * | anything unmatched | {} |
Static routes always win over param routes, so you can have both /users/new
and /users/:id and /users/new will match first.
Links
router.link(path, text) returns a normal <a href> — so middle-click,
Ctrl/Cmd-click, and "open in new tab" all work. A plain left-click is
intercepted and routed without a reload.
The active link automatically gets aria-current="page", so you can style it
with plain CSS:
nav a[aria-current="page"] {
font-weight: 600;
}Dynamic params
The matched params are passed straight to your component:
function UserPage(params) {
return html`<h1>User ${params.id}</h1>`;
}
// route: "/users/:id" → /users/42 → params.id === "42"Params are always strings (convert with Number(params.id) if you need a number).
Navigation from code
router.go("/about"); // navigate
router.go(`/users/${id}`); // build a pathgo() adds a history entry, so the back button returns to the previous page.
Query strings
// at /search?q=hello&page=2
router.query(); // → { q: "hello", page: "2" }query() is reactive — read it inside a binding (${() => router.query().q})
and it updates when the URL changes.
Hosting under a sub-path (base)
By default the router assumes your app owns the URL root (/). If it's served
under a sub-path — https://example.com/app/ or a project page like
https://you.github.io/repo/ — pass a base:
const router = createRouter(routes, { base: "/app" });Now everything stays clean and base-free in your code:
| | Without base | With base: "/app" |
|---|---|---|
| Route pattern | "/tasks" | "/tasks" (unchanged) |
| link("/tasks", …) href | /tasks | /app/tasks |
| go("/tasks") pushes | /tasks | /app/tasks |
| router.path() returns | /tasks | /tasks (still base-free) |
| query() | works | works |
The base is stripped before matching and prepended for links and navigation, so
you never repeat it. A trailing slash is optional ("/app" and "/app/" behave
the same).
Heads up — server rewrites. History-mode routing means a hard reload of a deep link (e.g.
/app/tasks/42) asks your server for that path. Configure your host to serveindex.htmlfor unknown paths under the base (a "SPA fallback"). In-app navigation needs no server config; this only matters for full reloads.
Intercepting content links (interceptLinks)
link() gives you an <a> that navigates client-side. But links you don't author
by hand — the ones inside rendered Markdown, a CMS body, or any HTML you didn't wrap
— are plain anchors, so clicking one triggers a full page reload (a visible flash, and
the whole app re-boots). Turn that off with one option:
const router = createRouter(routes, { interceptLinks: true });Now a plain left-click on any internal <a> navigates client-side, just like a
link(). It deliberately leaves alone everything that should behave normally:
- modifier / middle clicks and
target="_blank"(so "open in new tab" still works), downloadlinks, external origins, and non-HTTP schemes (mailto:,tel:),- same-page
#hashlinks (the browser scrolls natively), - links outside your
base, and any link you opt out with<a data-native href=…>.
It's off by default so nothing changes unless you ask. This is the piece that makes a content-heavy site (like docs) feel like a true SPA.
Server rendering
The router is SSR-safe: createRouter(...) and view() no longer touch
window/document when those don't exist, so a routed component can be passed to
@zoijs/ssr's renderToString without throwing. On the server view() renders the
matched route's template directly (and link() produces plain anchors), then the page
hydrates on the client where navigation, the History API, and interceptLinks take
over.
Routed SSR (rendering the request's route)
Pass the request URL as location so the server renders the route for that
request (it defaults to "/"). Use match() to learn the route + params first, so
you can load that route's data before rendering — then hand it to the client with
@zoijs/ssr's serialize and @zoijs/resource's
{ initial }:
import { renderToString, serialize } from "@zoijs/ssr";
import { createRouter } from "@zoijs/router";
async function handler(req, res) {
const router = createRouter(routes, { location: req.url });
const { params } = router.match(); // { component, params } for req.url
const data = await loadData(params); // your per-request data loading
const body = renderToString(() => App(router), { hydratable: true }); // renders the route
res.end(`<div id="app">${body}</div>
<script>window.__DATA__ = ${serialize(data)}</script>`);
}A route component seeds its resource from that data — the same expression works on the
server (where you set a global before rendering) and the client (where the embedded
<script> set window.__DATA__):
const User = (params) => {
const seed = (typeof window === "undefined" ? globalThis : window).__DATA__;
const user = resource(() => fetchUser(params.id), { initial: seed?.user });
return html`<h1>${() => user.data().name}</h1>`;
};There is no loader API and no component-signature change — routed SSR is location
match()plus the existing data primitives. (Automatic loaders / nested data orchestration stay out of scope by design.)
Common mistakes
Hosting under a sub-path without
base. The first load matches"*"(Not Found) because the URL has an extra prefix. Pass{ base: "/your-path" }.Calling
router.view()more than once. Place it a single time in your layout. It's the one spot where the current page renders.Using a real
<a href>for in-app links. That triggers a full page reload. Userouter.link(...)(or callrouter.go(...)) instead.Expecting numbers from params. They're strings:
Number(params.id).Forgetting the
"*"route. Without it, an unmatched URL renders nothing.Reading
router.path()outside a binding and expecting it to update. Like all Zoijs reactivity, wrap it in an arrow to make it live:${() => router.path()}.
What this router is not
By design, to stay tiny and beginner-friendly, it has no nested-outlet system, route guards, loaders/actions, providers, hooks, or SSR. If you need those, this probably isn't the router for you — and that's fine.
License
MIT © Zoijs contributors
