ROUTER
@amojs.dev/router
A separate package for client-rendered apps, built on the Navigation API (Baseline since January 2026) — the router you can uninstall. Three names: router, redirect, link. Static and server-rendered sites do not use it: there, a link is a document load and the file tree is the router.
The route table
import { html, mount } from '@amojs.dev/core';
import { router } from '@amojs.dev/router';
const app = router({
'/': () => import('./pages/home.js'),
'/users': () => import('./pages/users.js'),
'/users/:id': () => import('./pages/user.js'),
'*': () => import('./pages/404.js'),
}, {
pending: () => html`<p>loading…</p>`, // ONCE, for every page
error: (err, retry) => html`<p>${err.message} <button onclick=${retry}>retry</button></p>`,
});
mount(app, document.getElementById('app'));
The one idea everything rests on
data is a resolved value, not a resource. The router does not render a page until its load settles, so a page never sees a loading or error state — those live in one place, the router options. You never write a loading state again.
// pages/user.js
export async function load({ params, searchParams, signal }) {
return (await fetch(`/api/users/${params.id}`, { signal })).json(); // signal: the navigation's AbortSignal
}
export const title = (data) => data.name;
export default ({ data }) => html`<h1>${data.name}</h1>`;
load, action and the component all receive searchParams captured from the navigation's own URL, never from location, so a page superseded mid-load cannot read the newer navigation's query by accident. A same-path navigation with a different query re-runs load, so pagination just works.
What the platform does, so this package does not
<a href> just works — no <Link> component, no document-level click listener, no modifier-key edge cases. Every navigation carries an AbortSignal: hand it to fetch and an abandoned page's request cancels itself. Scroll restoration on back/forward is free. Forms are intercepted and handed to the page's action:
export async function action({ formData }) {
await fetch('/api/contact', { method: 'POST', body: formData });
return redirect('/thanks');
}
export default () => html`<form method="post"><input name="email" required /><button>send</button></form>`;
Browsers without the Navigation API get full page loads — the app is slower, not broken. No polyfill, no bytes.
link() under a base
import { link } from '@amojs.dev/router';
html`<a href="${link('/users')}">users</a>`; // the base prefix, bound at router() creation; identity with no base
Options
basestringpath prefix the app lives underpending() => Nodeshown once a load passes 100 ms — fast pages swap old → new with no flasherror(err, retry) => Nodeshown when load or action throw; retry re-runs the navigationviewTransitionsbooleancrossfade page swaps via the View Transitions API; off by defaultTwo behaviours to know
The tab spinner runs during load. During an intercepted navigation the browser shows its real loading UI until the handler settles — the platform's honest progress indicator, whose stop button feeds the abort signal for free. It is not a page reload. The URL commits before the content arrives. With pending UI this is correct, but it differs from React Router. Redirect loops are capped at ten hops and reported through the error UI; size is gated in CI at 1328 bytes min+gzip; amo eject hands the router over with the runtime.