SPA router

The framework ships a tiny client-side router — spa.js — that turns ordinary multi-page navigation into a single-page experience without a build step, a dependency, or a framework runtime. It's a few KB of plain browser JavaScript in the same spirit as the rest of the stack: native ES module, modern browser APIs, zero dependencies.

It is opt-in and progressive. A page enhances itself by including the module:

<script type="module" src="/spa.js"></script>

With the script absent — or JavaScript disabled — every link is an ordinary full-page navigation. The router only ever enhances; it can never be the reason a link fails.

What it does

  1. Intercepts same-origin link clicks.
  2. fetches the target page's HTML.
  3. Parses it with DOMParser and swaps the live <main> for the fetched

document's <main>, updating document.title.

  1. Pushes a new history entry and scrolls to the top.
  2. Animates the swap with the View Transitions API when available.

The header, footer, and any scripts they loaded stay put — only the content region changes, so navigation feels instant.

Link interception rules

A click is handled by the router only when all of these hold. Anything that fails a check is left to the browser as a normal navigation:

tab/window, etc.) pass through untouched.

scroll natively; they are not intercepted.

Only GET-style navigations are involved — links never carry a body, so there's nothing else to honor.

Fetch, swap, history

On an intercepted click the router calls preventDefault() and fetches the URL with accept: text/html and same-origin credentials. The response is parsed and its <main> replaces the current one; the <title> is copied over. Then history.pushState records the new URL and the window scrolls to the top.

Scroll restoration is set to manual so the router controls it: a forward navigation scrolls to the top, while back/forward (popstate) lets the browser restore the previous scroll position. popstate re-fetches and swaps the target page without pushing a new entry.

View Transitions & reduced motion

When the browser supports document.startViewTransition, the DOM swap is wrapped in it for a smooth cross-fade between the old and new content. Where it isn't supported, the swap happens immediately — same result, no animation.

The router respects prefers-reduced-motion: reduce: if the user has asked for less motion, it skips the View Transition and swaps instantly.

Progressive enhancement guarantee

The router is additive and fail-safe:

falls back to a real navigation (location.assign, or a reload on popstate).

So the worst case is simply "the browser navigates normally" — never a broken link.

The spa:navigated hook

After each successful navigation the router dispatches a spa:navigated CustomEvent on document. Because a swap replaces <main>, any scripts that wired up behavior inside it should re-initialize on this event:

document.addEventListener("spa:navigated", (e) => {
  // e.detail = { url, mode }   mode is "push" or "pop"
  initMyWidgets(); // re-bind anything that lived inside the swapped <main>
});

mode is "push" for a clicked link and "pop" for back/forward navigation.

API

The module auto-initializes on load. It also exposes a minimal handle:

There's a live demo at /spa.