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

app.js
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
// 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:

pages/contact.js
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.

JS
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

OPTIONTYPEMEANING
basestringpath prefix the app lives under
pending() => Nodeshown once a load passes 100 ms — fast pages swap old → new with no flash
error(err, retry) => Nodeshown when load or action throw; retry re-runs the navigation
viewTransitionsbooleancrossfade page swaps via the View Transitions API; off by default

Two 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.