AkurAI Build
Menu

Public repository / beddeb15df67607b460f8291beb5c20f68db988e

AkurAI-Framework / site/content/docs/spa-router.md

Latest change 3a1d4dc0df9425a910df82402300a2aeb40318ae - feat(frontend): client-side SPA router with View Transitions by Ólafur Búi Ólafsson

# 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:

```html
<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. `fetch`es the target page's HTML.
3. Parses it with `DOMParser` and swaps the live `<main>` for the fetched
   document's `<main>`, updating `document.title`.
4. Pushes a new history entry and scrolls to the top.
5. 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-`_self` target) opts out.
- **Not a download.** `download` links opt out.
- **Not external.** `rel="external"` opts out.
- **Not a hash-only / in-page anchor.** Links to `#section` on the current page
  scroll natively; they are not intercepted.
- **Escape hatch.** Add `data-no-spa` to 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:

```js
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`](/spa).