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
- Intercepts same-origin link clicks.
fetches the target page's HTML.- Parses it with
DOMParserand swaps the live<main>for the fetched
document's <main>, updating document.title.
- Pushes a new history entry and scrolls to the top.
- 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:
- Left click, unmodified.
Ctrl/Cmd/Shift/Alt-clicks (open in new
tab/window, etc.) pass through untouched.
- Same origin. Links to other origins are real navigations.
- HTTP(S) only.
mailto:,tel:, and other schemes are ignored. - Not a new-tab target.
target="_blank"(or any non-_selftarget) opts out. - Not a download.
downloadlinks opt out. - Not external.
rel="external"opts out. - Not a hash-only / in-page anchor. Links to
#sectionon the current page
scroll natively; they are not intercepted.
- Escape hatch. Add
data-no-spato any link to force a full navigation.
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:
- Without
spa.js, links are plain navigations. - If a fetch or parse fails, or the fetched document has no
<main>, the router
falls back to a real navigation (location.assign, or a reload on popstate).
- It claims at most one global,
window.SpaRouter, and initializes once, guarded.
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:
SpaRouter.navigate(url)— navigate programmatically (pushes history).SpaRouter.start()— idempotent; the module calls it for you.
There's a live demo at /spa.