Client router
The client router turns a multi-page site into an SPA without a framework
reload cycle: it intercepts internal link clicks, fetches the rendered
payload, swaps the DOM, merges <head>, restores scroll, and re-hydrates
islands — all with zero page flicker.
# Setup
The router ships as its own entry (/_elur/router.js) when the client
bundle uses split inputs — the default generated config:
// vite.client.config.ts
rollupOptions: {
input: {
"entry-client": ".elur/entry-client.ts", // islands hydration
"router": ".elur/router.ts", // SPA router
},
output: { entryFileNames: "[name].js" },
}/.elur/router.ts is auto-generated and just calls
startClientRouter({...}) with the flags baked in from elur.config.ts:
export default defineConfig({
router: {
enabled: true,
prefetch: true,
morph: false,
loadingIndicator: false,
speculation: "prefetch", // static builds only
},
});A single-input client config keeps working — the router is embedded in
entry-client.js (legacy combined mode). js: "legacy" forces that mode
explicitly.
# Per-page JavaScript
Pages only pay for what they use — the shell scans the rendered HTML for
data-elur-island markers:
| Page contents | router.enabled |
Scripts emitted |
|---|---|---|
| No islands | false |
none — 0 KB of JS |
| No islands | true |
router.js |
| Islands | false |
entry-client.js |
| Islands | true |
entry-client.js + router.js |
Every emitted module entry gets <link rel="modulepreload">.
# Navigation lifecycle events
document.addEventListener("elur:navigate-start", (e) => {
const { pathname, search, fromCache, popstate } = e.detail;
});| Event | When | detail |
|---|---|---|
elur:navigate-start |
Navigation begins | { pathname, search, fromCache, popstate } |
elur:before-render |
Before #app is swapped — the old DOM is still attached |
{ pathname, search, popstate, persisted } (persisted: the live Elements that will survive) |
elur:rendered |
After the swap + head merge + script re-execution | { pathname, search, popstate } |
elur:navigate-end |
After the DOM update completed (view-transition updateCallbackDone — not when the animation finishes) |
{ pathname, search, fromCache, popstate } |
elur:navigate-error |
Fetch/render failure (the error is rethrown, not attached to detail) |
{ pathname, search, fromCache, popstate } |
elur:persist-props-changed |
Persisted island received new props | { name, props, previousProps } — bubbles from the island marker |
The generated entry uses elur:before-render to dispose islands (except
persisted ones) and elur:rendered to re-hydrate. Superseded navigations
abort silently — no navigate-error is dispatched.
# data-elur-persist — surviving navigations
Give an element a persist key and the router moves the same live DOM node into the new page instead of re-rendering it:
<div data-elur-persist="player">
${island("AudioPlayer", AudioPlayer, { track }, "load")}
</div>- Matched by attribute value (
"player"); a bare attribute never persists. - Uses
Element.moveBefore()when available (replaceWithfallback). - Islands inside keep their state — they are not disposed or re-hydrated.
- If the island's serialized props changed,
elur:persist-props-changedfires on the marker. - Great for media players, sidebars, canvases, scrollable panels.
# Prefetching
import { prefetch } from "@elurjs/kit/router";
await prefetch("/docs/islands");
await prefetch("/heavy-page", "", { force: true }); // bypass network guards- Triggers:
pointerenter,focus,pointerdown— plus opt-in viewport prefetch withdata-prefetch="viewport"(IntersectionObserver, 200 px margin). - Bounded LRU cache: 32 entries, 30 s TTL.
- Network-aware: skipped on
Save-DataoreffectiveType2g/slow-2g;data-prefetch="always"or{ force: true }overrides. data-no-prefetchon a link disables prefetching for it.
# DOM morphing (experimental)
defineConfig({ router: { morph: true } })#app is morphed with idiomorph instead of replaced — transient state
outside islands survives (form values, open <details>, element scroll).
Hydrated islands and persisted nodes are opaque to the morph. If morphing
throws, the router falls back to a normal swap automatically.
morph is opt-in and experimental. Measure before adopting — it is not
the default yet.
# Loading indicator
defineConfig({ router: { loadingIndicator: true } })A thin top progress bar (#elur-loading-indicator) appears after ~200 ms
of navigation and completes when the transition ends. Never shown on
cache hits; rendered as a static bar under prefers-reduced-motion.
# Speculation Rules
defineConfig({ router: { speculation: "prefetch" } }) // or "prerender"Emits <script type="speculationrules"> with eagerness: "moderate" on
static and hybrid-prerendered pages — Chromium prefetches (or prerenders)
internal pages on hover intent. The rule scopes to same-origin paths
(href_matches: "/*") and excludes a[download], a[target],
a[data-no-router], and a[data-no-speculation] — add
data-no-speculation to any link Chromium should never speculate.
# Scroll restoration
history.scrollRestoration is set to "manual". Scroll position is saved
per history entry ({ n, scroll }) and restored on back/forward; forward
navigations scroll to top and move focus to the main content. Elements
with data-scroll-preserve keep their own scroll across navigations.
# What happens on a navigation
elur:navigate-startdispatched; in-flight navigation is cancelled.GET /__elur-js/render?page=...(skipped on static builds — theelur:render-endpointmeta disables the probe and full HTML is fetched instead).elur:before-render— islands are disposed while still attached.- Persisted nodes are moved into the incoming fragment.
#appswapped (or morphed withrouter.morph).<head>merged (data-elur-headtags, title, canonical,og:url);#elur-data/#elur-actionsJSON refreshed; inline<script>s re-executed (externalsrcdeduplicated across navigations,data-elur-no-reloadopts out);<link rel="stylesheet">and<style>tags inside#appare hoisted into<head>so they persist across navigations (prevents FOUC).elur:rendered→ islands re-hydrate.elur:navigate-endafter the view transition settles.
# The render payload
GET /__elur-js/render?page=<path> returns JSON with all keys always
present (missing values are null, not omitted — stable wire shape):
interface RenderPayload {
title?: string | null; // <title> for the new page
body: string; // rendered #app HTML
head?: string | null; // <head> tags to merge (title, meta, OG, twitter)
data?: string | null; // serialized <script id="elur-data"> content
actions?: string | null; // serialized <script id="elur-actions"> content
clearActionErrorCookie?: string | null; // Set-Cookie to clear a consumed action error
}When the response clears an action error cookie it is also relayed as the
X-Elur-Action-Clear-Cookie response header (parity with the dev path).
# hoistStyles(container)
Style hoisting is also available as a standalone export — it moves
<link rel="stylesheet"> and <style> elements found inside a container
into <head>, deduplicating by href/content:
import { hoistStyles } from "@elurjs/kit/router";
hoistStyles(fragment); // hoists styles before the #app swap# navigateTo / prefetch
import { navigateTo, prefetch } from "@elurjs/kit/router";
await navigateTo("/blog/hello-world"); // push + swap
await navigateTo("/login", "", false); // replace-style (popstate)
await prefetch("/docs/islands"); // cache for 30 s
await prefetch("/heavy", "", { force: true }); // bypass network guards