Skip to content

Client Router

WebJs ships a nested-layout-aware client router that intercepts same-origin <a> clicks and <form> submissions, fetches the target HTML, and swaps only the deepest layout boundary the two pages don't share. Outer layout DOM is preserved: sidenav scroll, input values, <details> open state, mounted custom elements all survive navigation without authors writing anything.

The router is automatic and needs no import: it auto-enables whenever @webjsdev/core loads in the browser, which happens on any page that ships a component. For 99% of apps the contract is "write standard HTML, navigation gets faster." The advanced primitives below (frames, revalidation, programmatic navigation) exist for the cases where you need to take over.

The one edge: a fully-static page with zero components ships no JavaScript at all, so it has no router and its links do a normal full-page navigation (correct progressive enhancement, and cheaper). This is invisible during a session, since a router started on any earlier interactive page stays active across soft navigations. It only shows on a cold direct load of such a page (a bare error or 404 screen). If you want soft navigation there too, render any component in the page or its layout, or add import '@webjsdev/core/client-router' to force the router on.

Opting out app-wide. If you want plain full-page navigation everywhere (a classic multi-page app) even though you ship interactive components, set { "webjs": { "clientRouter": false } } in package.json. Components still hydrate and stay interactive; only the link / form interception is turned off, so every navigation is a full browser load. To turn it off for just one moment at runtime, call disableClientRouter() (and enableClientRouter() to turn it back on), both from @webjsdev/core.

How it works (auto-magic, no opt-in)

  1. SSR emits KEYED boundary comment pairs around each layout's ${children} interpolation and around the page itself: open <!--wj:children:<segment>:<route-key>-->, close <!--/wj:children:<segment>-->. The segment is the folder-derived pattern; the route-key is the resolved path for this request (dynamic params substituted, values percent-encoded). Derived from folder structure, with authors writing nothing.
  2. On a click or form submit, the router STRICTLY scans both the live DOM and the incoming HTML into segment maps (a close must match its innermost open; any truncated, mispaired, or duplicated boundary poisons the scan) and picks a two-tier swap with Next.js parity: a boundary whose route-key CHANGED is wholesale REPLACED at the PARENT of the shallowest change (a layout's boundary wraps only its children, so anchoring at the parent remounts the changed layout's own markup too, exactly like Next re-rendering a layout with new params), while an all-keys-equal nav (a searchParams-only change) MORPHS the deepest shared boundary in place so hydrated component state survives. A poisoned scan or no shared boundary degrades to a normal full page load, never a guessed swap, so a malformed response cannot corrupt the live DOM. Because the boundaries are comments, the parse that turns a response into a document has to preserve them: Document.parseHTMLUnsafe strips every comment in some browser versions, so the router probes it once and parses with DOMParser instead when it is lossy.
  3. The diff inside the swap region is keyed by data-key or id. Matched elements are reused with in-place attribute updates. Live attributes (value, checked, selected, indeterminate, disabled, open, popover) are never overwritten, so user input and disclosure state survive the swap.
  4. The <head> is add-only merged (preserves runtime-injected styles like Tailwind's), <script> tags re-execute, custom elements upgrade, URL updates via pushState.
  5. A webjs:navigate event fires on document with the final URL.

Wire-byte optimization: the router sends an X-Webjs-Have request header listing segment:route-key entries for the boundaries it already has (the key lets the server re-render a dynamic layout the client holds for different params instead of skipping it). The server walks the target page's layout chain innermost-to-outermost, short-circuits at the first match, and returns only the divergent fragment wrapped in that layout's boundary pair. Outer layouts are never re-serialized for same-shell navigations, and a reduced response carries Vary: X-Webjs-Have so a shared cache can never serve it to a full-page navigation.

Progressive streaming on navigation

When the destination streams (it has a Suspense or <webjs-suspense> boundary), the router applies the response PROGRESSIVELY: it swaps the shell (with the fallbacks) in immediately and advances the URL, then streams each resolved boundary into the live DOM as it arrives, fast-before-slow. So a soft navigation to a streamed page matches the initial-load experience (fallback first, content streams in) instead of buffering the whole response before the swap. A non-streaming page is unaffected (the response is read to completion and applied once). A navigation superseded mid-stream stops applying, and a mid-stream transport failure leaves the applied boundaries in place with the rest showing their fallback (non-destructive).

Form submissions

<form action="/x" method="post"> works exactly per the HTML spec. WebJs intercepts the submit event in the bubble phase (after a component's own @submit handler) and routes the same fetch the browser would have sent through the partial-swap pipeline. Because it runs after, a component that calls e.preventDefault() in @submit keeps the form to itself and the router leaves it alone; the same applies to @click on links. Submitter attributes (formmethod, formaction, formenctype on a clicked <button>) take precedence over the form's own per HTML5.

  • GET forms: FormData is promoted to the URL query string (replacing any existing query on action). The URL is then fetched and applied like a link click.
  • POST / PUT / PATCH / DELETE forms: FormData is sent as the request body. After a successful response the snapshot cache is cleared (other cached URLs may reflect stale server state).

Forms that handle submission in JavaScript (@submit=${e => { e.preventDefault(); /* RPC */ }}) are untouched. The router only intercepts when event.defaultPrevented is false.

Auto-skipped (no opt-out needed):

  • method="dialog": browser-native <dialog> dismissal
  • target / formtarget_self: iframes, popups, named windows
  • Cross-origin action
  • Non-HTML extensions on the action URL

Submission state (webjs:submit-start / webjs:submit-end + aria-busy)

When a <form> submits through the JS-enhanced router, the form gets a submission lifecycle a component can read to disable the submit button, show a spinner, or set a pending style.

  • The router sets the native aria-busy="true" on the form for the in-flight duration (cleared on settle). This IS the readable "is this form submitting" primitive. Any component can poll form.getAttribute('aria-busy') or style form[aria-busy="true"] in CSS.
  • It dispatches a bubbling webjs:submit-start (detail { form, url }) when the submission fetch starts, and webjs:submit-end (detail { form, url, ok }, where ok is whether the submission settled as a success) on EVERY settle (a success, a 4xx/5xx validation re-render, a navigation error, or an abort by a superseding submit). The pair is balanced even under a rapid re-submit (a nav-token guard keeps a superseded submit's teardown from clearing the busy state a newer submit set, the same guard <webjs-frame> uses).
// A submit button that disables itself while its form is submitting.
form.addEventListener('webjs:submit-start', () => { button.disabled = true; });
form.addEventListener('webjs:submit-end', (e) => {
  button.disabled = false;            // e.detail = { form, url, ok }
});
/* or purely in CSS, no JS: */
/* form[aria-busy="true"] button[type="submit"] { opacity: .5; pointer-events: none; } */

Progressive enhancement is unaffected. With JS off the form is a normal POST. The events and aria-busy are a client-only enhancement.

Optimistic mutations (optimistic())

WebJs ships two signatures for optimistic UI: a declarative React 19-style state wrapper (recommended for collections) and a legacy imperative signal-based helper (ideal for single-value toggles).

Declarative: optimistic(host, { source, update }) manages a queue of pending updates, computes the combined value through a reducer, and auto-releases when a passed promise settles.

import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
import { createTodo } from '#actions/create-todo.server.js';

class TodoList extends WebComponent({ todos: prop(Array) }) {
  constructor() {
    super();
    this.todos = [];
    this.optTodos = optimistic(this, {
      source: () => this.todos,
      update: (state, title) => [...state, { id: 'tmp', title, pending: true }],
    });
  }
  async handleSubmit(e) {
    const title = e.target.querySelector('input').value;
    const promise = createTodo({ title });
    this.optTodos.add(title, promise);  // auto-releases on settle
    const result = await promise;
    if (result.success) this.todos = [...this.todos, result.data];
  }
  render() {
    return html`<ul>${this.optTodos.value.map(t =>
      html`<li class=${t.pending ? 'opacity-50' : ''}>${t.title}</li>`)}`;
  }
}

Imperative: optimistic(signal, value, action) sets the signal to value immediately, runs action(), and rolls back on a thrown error or { success: false } envelope.

import { signal, optimistic } from '@webjsdev/core';
import { likePost } from '#actions/like-post.server.js';

const liked = signal(false);
const result = await optimistic(liked, true, () => likePost(postId));
// liked flips to true instantly. Rolls back on throw or failure envelope.
On success the optimistic value stays; reconcile from result if needed.

Both signatures are client-only, so a component importing optimistic is never elided as display-only. See Data Fetching for the full reference.

Non-2xx HTML responses render in place

Any response with a text/html body is applied to the DOM regardless of status code. This makes the standard server-rendered validation pattern work end-to-end:

  • 2xx: normal navigation.
  • 4xx (e.g. 422): server re-renders the form with value attributes preserving what the user typed, inline error messages visible, no full-page reload. The Rails / Django / Laravel / Phoenix server-side validation flow.
  • 5xx with HTML: error page rendered in place (not a flash of blank then reload).

Non-HTML error responses (a JSON error envelope from a 500), and transport/parse failures, recover in place via the webjs:navigation-error event below rather than a destructive full reload.

204 No Content: DOM untouched. History records the requested URL ("stay on current page" pattern for autosave-style submissions).

3xx redirects: fetch() follows them automatically. The final URL after redirects is recorded in history (Post-Redirect-Get pattern works correctly).

Failed navigations recover in place (webjs:navigation-error)

A successful swap and an HTML error body of any status both apply in place (above). The remaining failure cases are a non-HTML error response (a 500 carrying a JSON body) and a transport/parse failure (the fetch rejected, or the body claimed HTML but did not parse). For those the router no longer abandons the SPA with a destructive full location.href reload (which would discard the partial-swap shell, scroll, focus, and in-flight client state, and eat a second round-trip that may itself fail to the browser's default error page).

Instead the router dispatches a cancelable, bubbling webjs:navigation-error event on document, with detail { url, status, error }: status is the HTTP status when a response arrived (else null), and error is the Error for a transport/parse failure (else null).

  • preventDefault() hands recovery to your app. The router does nothing further, so the current page is left exactly as it is (shell, scroll, focus, and client state preserved). Show a toast, retry, or navigate elsewhere.
  • Not cancelled (the default) renders a minimal in-place error surface, a <div role="alert"> carrying a generic message plus the status, into the deepest layout children slot (the same target a normal partial swap writes to, so outer chrome and nav are preserved).
  • Last-resort hard load happens only when there is no shared layout marker to render into (a genuine cross-document nav), and only after the event was not cancelled.

An AbortError (a newer navigation superseding this one) is a normal supersede, not an error, and never fires webjs:navigation-error.

document.addEventListener('webjs:navigation-error', (e) => {
  // e.detail = { url, status, error }
  e.preventDefault();                 // app handles recovery; page left intact
  showToast(`Could not load ${e.detail.url} (status ${e.detail.status})`);
});

Strip transient state before back/forward (webjs:before-cache)

Back/Forward restores from a URL-keyed snapshot cache (Turbo's SnapshotCache pattern) for instant navigation. Because a snapshot is a raw outerHTML clone of the live page, anything open when you navigate away (a hover-card, a dropdown, a toast) is captured open and restored open on Forward. The router dispatches webjs:before-cache on document synchronously, on the page being cached, right before the snapshot is read, so a handler can reset that state and edits land in the snapshot. The kit's overlays already do this, so they come back closed.

document.addEventListener('webjs:before-cache', () => {
  document.querySelectorAll('[data-transient]').forEach((el) => el.remove());
  // close open menus, clear in-progress toasts, reset a wizard step, ...
});

<webjs-frame>: escape hatch for non-layout regions

<webjs-frame> is webjs's take on Turbo Frames (from Hotwire Turbo), so if you know <turbo-frame> the model transfers directly: a lazy, URL-addressable region that swaps on its own, driven by a link or form that targets its id. See Data fetching for when to reach for a frame versus async render, <webjs-suspense>, or <webjs-stream>, and for combining a lazy frame with streamed content inside it.

The marker mechanism scopes swaps to the deepest shared layout. When you need a swap region smaller than the deepest layout (typically a widget inside a page that should swap independently of the rest of the page) wrap it in <webjs-frame id="...">.

// app/posts/[slug]/page.ts
export default async function PostPage({ params }) {
  const post = await getPost(params.slug);
  return html`
    <article>${post.body}</article>

    <webjs-frame id="comments">
      ${await renderComments(post.id, /* page */ 1)}
      <a href="/posts/${params.slug}/comments?page=2">Load more</a>
    </webjs-frame>
  `;
}

When the user clicks "Load more", the router's closest('webjs-frame') from the click target finds #comments. The fetched response is expected to contain a <webjs-frame id="comments"> too. Only its children swap into the live frame, leaving the article body (and any reading scroll position, video playback, etc.) fully intact.

This takes precedence over the layout-marker mechanism. Most apps never need it. Only reach for it when you've identified that the auto-marker swap is wider than the actual change.

External targeting (data-webjs-frame) and _top

A trigger does not have to be nested inside the frame it drives. Mirroring Turbo's data-turbo-frame, an <a> or <form> (or any ancestor of it) carrying data-webjs-frame="<id>" drives the frame with that id from anywhere in the document, resolved via getElementById. So an external nav/sidebar link or a filter form can drive a content frame it does not enclose.

<nav data-webjs-frame="results">
  <a href="/products?sort=new">Newest</a>
  <a href="/products?sort=top">Top rated</a>
</nav>
<form action="/products" data-webjs-frame="results">…filters…</form>

<webjs-frame id="results">…current results…</webjs-frame>

An explicit data-webjs-frame WINS over the closest-enclosing-frame default. The reserved token data-webjs-frame="_top" on a trigger INSIDE a frame breaks OUT to a full-page navigation. An id that does not resolve to a live <webjs-frame> warns once and falls back to a normal navigation (it never throws). With JS disabled the attribute is inert on a plain <a href>, so the click is a normal full navigation, the correct progressive-enhancement fallback.

Busy state (aria-busy + webjs:frame-busy)

While a frame's navigation is in flight the router sets the native aria-busy="true" on the frame element and clears it (to "false") on every exit, a successful swap, a frame-missing response, an HTTP/transport error, or an abort by a newer navigation. So assistive tech announces the loading state, and CSS can style the busy region with webjs-frame[aria-busy="true"]. The router also dispatches a bubbling webjs:frame-busy event on the frame at both edges (detail { frameId, busy }, true at start then false at finish) for app-level hooks.

Self-loading frames (src + loading)

A frame can fetch its OWN content instead of waiting for a click or a form. Give it a src and it self-fetches that URL as a frame nav and applies the matching <webjs-frame id> subtree from the response into itself, through the same frame-swap path (so the busy lifecycle, the navigation-error recovery, and the frame-missing fallback all apply). The loading attribute picks when: eager (or absent) fetches on connect, lazy fetches when the frame first scrolls into view (reusing the same IntersectionObserver budget as a static lazy = true component).

<webjs-frame id="comments" src="/posts/42/comments" loading="lazy">
  <p>Loading comments...</p>
</webjs-frame>

A src change after connect re-loads; eager connect, the lazy observer, and a src mutation never double-fetch the same URL. Because the request carries the x-webjs-frame header, the server returns only the matched subtree (byte-equivalent to what the client would slice from a full-page render, but far fewer bytes), falling back to the full page when the frame is absent.

Progressive-enhancement caveat: a src-driven frame is JS-dependent. The browser does not natively fetch a <webjs-frame src> (unlike an <iframe>), so with JS off the frame shows only whatever children were server-rendered into it. Use src / loading for deferred content (comments, a recommendations rail, an expensive card) where a JS-off placeholder is acceptable; for content that must exist without JS, render it server-side into the frame instead.

Stream actions (surgical element updates)

<webjs-stream> is webjs's take on Turbo Streams (from Hotwire Turbo); the action set (append / prepend / before / after / replace / update / remove) mirrors <turbo-stream>, so that knowledge transfers directly.

A region swap is the right tool for "this part of the page changed". It is too coarse for "append ONE comment", "remove ONE row", or "bump a count". For those, a server response declares per-element actions as plain HTML, a <webjs-stream action target> wrapping one <template>:

<webjs-stream action="append" target="comments">
  <template><li>Nice post!</li></template>
</webjs-stream>

The element clones its template on connect, applies the action by native DOM, then removes itself. Actions mirror Turbo's set: append / prepend (last / first child of the target id), before / after (sibling of the target), replace (the target element), update (its children), remove (delete it, no template). A targets CSS selector applies to every match instead of a single target id.

One applier serves two delivery paths. Over HTTP, a <form> submission rides the router, which adds Accept: text/vnd.webjs-stream.html; the server returns a stream only then and the router applies it surgically. With JS off the browser sends no such header, so the same endpoint returns a normal render and the form is a plain full-page POST (progressive-enhancement-safe). Over a live channel, renderStream(message) from a connectWS handler applies a broadcast()ed payload, so chat and notifications reuse the same applier.

Build the payload server-side and apply it client-side:

// app/posts/[id]/route.ts
import { stream, streamResponse, acceptsStream, broadcast } from '@webjsdev/server';
import { escapeText } from '@webjsdev/core';
export async function POST(req, { params }) {
  const c = await addComment(params.id, await req.formData());
  const html = stream.append('comments', '<li>' + escapeText(c.text) + '</li>');
  broadcast('post:' + params.id, html);              // fan out to other viewers
  if (acceptsStream(req)) return streamResponse(html); // JS client: surgical
  return Response.redirect(new URL('/posts/' + params.id, req.url), 303); // no-JS: normal render
}
// a component, for the live channel
import { connectWS, renderStream } from '@webjsdev/core';
connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });

stream.* escapes the target id but NOT the content (server-authored HTML, like an html hole, so escape any user substring yourself with escapeText from @webjsdev/core). renderStream and the <webjs-stream> element are auto-registered by the client router.

View Transitions (opt-in, all three swap paths)

The router can wrap a client navigation's DOM mutation in the native View Transitions API (document.startViewTransition), so a same-shell partial swap cross-fades (or runs your ::view-transition-* CSS) instead of snapping. It is OFF by default and purely OPT-IN, so an unconfigured app behaves exactly as before (no animation surprise, no regression in a browser without the API). Opt in by adding a meta to the page head, mirroring Turbo's <meta name="view-transition"> convention:

<!-- in the root layout's <head>, or any page's head -->
<meta name="view-transition" content="same-origin">

The accepted opt-in value is same-origin (every client-router swap is same-origin by construction, so it reads as "animate these in-app navigations"); any other value, or the meta being absent, keeps transitions off. The opt-in is PER page, so it is a page-scoped meta: put it on one page's metadata to animate that page, or on the root layout to animate the whole app. Navigating to a page that does NOT declare it turns transitions back off, because the soft-nav head merge reconciles page-scoped <meta> tags (a stale one the previous page declared is removed, not left to leak).

View transitions COMPOSE with Suspense streaming: a streamed boundary (a loading.ts skeleton or a <webjs-suspense> region) navigated to under an active transition still resolves its content progressively, because the streamed resolve waits for the transition's DOM swap to commit before applying.

When enabled and supported, the transition wraps ALL THREE swap paths, the deepest-marker layout swap, the <webjs-frame> swap, AND the full-body fallback, not just the full-body case (the inverse of what an author expects, since the marker and frame swaps are the common designed-for paths). The transition wraps the DOM MUTATION ONLY, never the fetch (which already happened); the browser captures the before/after around the synchronous swap. When startViewTransition is unavailable (Firefox / older Safari), the swap runs synchronously, byte-identical to the no-transition path, with no flash and no throw.

Persisting elements across a swap (data-webjs-permanent)

An element marked data-webjs-permanent (it MUST also carry an id) survives a navigation as the SAME live DOM node, by node identity, so a playing <audio> / <video>, a live widget, an open menu, or any element with accumulated JS state keeps running across the swap instead of being destroyed and re-created from the incoming HTML. Mirrors Turbo's permanent-element behaviour.

<audio id="player" data-webjs-permanent controls src="/track.mp3"></audio>

Mechanism: before the destructive swap, for each [data-webjs-permanent][id] in the CURRENT DOM the router looks for a matching #id in the INCOMING document; when BOTH exist, the LIVE node is moved into the incoming tree's position (replacing the incoming placeholder), so the swap adopts the live node rather than recreating it. It works for the full-body path AND the in-region (marker / frame) paths, and is a STRONGER guarantee than the keyed reconciler (which preserves identity for matched keyed children): a permanent node keeps EXACT identity even where the reconciler would otherwise recreate it. Rules:

  • The element must have an id (the match key) and the attribute on BOTH the current and incoming render of the page.
  • An id present in the current but ABSENT from the incoming doc is NOT force-persisted (it is being removed; the swap removes it as usual).
  • Only a CURRENT node actually carrying data-webjs-permanent is moved (an incoming #id that resolves to a non-permanent current element is left untouched).
  • The node is placed exactly where the incoming document puts it, so it never escapes a frame / region boundary.

Progressive enhancement: with JS off, data-webjs-permanent is an inert attribute and the navigation is a normal full-page load.

Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. It jumps like a native page load. A hash-anchor (#section) link still scrolls smoothly when you opt into it. Because route transitions ignore scroll-behavior: smooth (it only affects in-page anchors), the router logs a one-time dev-only console hint if it detects that setting on <html>, and notes that combining it with a sticky backdrop-filter header can flash on iOS during navigation.

After a server action mutates data that a cached page depends on, call revalidate():

import { revalidate } from '@webjsdev/core';

// Invalidate one cached URL, next visit refetches
revalidate('/products/123');

// Clear the entire cache, useful after broad mutations
revalidate();

Mutating form submissions (POST / PUT / PATCH / DELETE) clear the cache automatically on success. You only need revalidate() when the mutation happens via JS / RPC and didn't go through a form.

Link prefetch (on by default)

Same-origin in-app links are prefetched speculatively, so a click resolves from a warm cache with no round-trip. No attribute is needed; it is on for every internal <a href>, the way Next, Nuxt, and SvelteKit ship auto-prefetch, and the prefetch sends the same headers a real navigation does so the click consumes the fragment.

The default strategy is device-adaptive, because one strategy cannot serve both input modalities. On a hover-capable pointer (mouse / trackpad) the default is intent (warm on hover or focus, a real head-start before the click). On touch the default is viewport (warm as links settle on-screen), because touch has no hover and touchstart fires at tap time, too late to help. The modality is detected with matchMedia('(hover: hover) and (pointer: fine)'), not a user-agent sniff, and a per-link data-prefetch always overrides it.

Choose a strategy per link with data-prefetch (a valid-HTML data-* attribute, since WebJs has no Link component). Next-style aliases are accepted:

<a href="/dashboard">adaptive: intent on pointer, viewport on touch (default)</a>
<a href="/dashboard" data-prefetch="intent">hover / focus / touch</a>
<a href="/dashboard" data-prefetch="render">eager, on insert (alias: true)</a>
<a href="/dashboard" data-prefetch="viewport">on scroll into view (alias: auto)</a>
<a href="/dashboard" data-prefetch="none">never (alias: false)</a>

The viewport strategy waits a ~250ms dwell before warming and cancels the instant a link scrolls back out, so a fast scroll through a long list spends no requests (the same over-fetch gate Astro, Next, Nuxt, Remix, TanStack, and Turbo apply). On touch, touchstart additionally warms the tapped link itself. The guiding rule is snappy without bloating the network tab: when the two conflict, the gate under-fetches.

Only internal links qualify, using the same eligibility as a click: cross-origin, download, target other than _self, non-HTML extensions, data-no-router, and pure hash jumps are skipped. Opt out with data-prefetch="none", data-no-prefetch, or rel="external". Speculation is bounded (a concurrency cap with a draining queue, in-flight de-dupe, an LRU + TTL cache) and is disabled under Save-Data, prefers-reduced-data, or a 2g connection. A mutating form submission and revalidate() evict the prefetch cache too, so a fragment prefetched before a mutation is never served stale.

Prefetch issues a real GET, so a non-idempotent action (logout, anything that mutates) must be a POST or a <form>, never a GET link. This matches every framework that auto-prefetches. A native <link rel="prefetch"> in the document head is the browser's own mechanism and is left untouched.

The prefetch cache is anchor-validated, not just URL-keyed

A prefetched fragment is a reduced response. The request carries X-Webjs-Have (the layout boundaries the client already holds) and the server returns only the divergent part, starting at the deepest boundary it short-circuited on. That boundary is the fragment's anchor, and the fragment applies to any live DOM that still offers it with the same route-key.

So on consume the router validates the anchor, not the whole have string. A fragment anchored at the root layout survives an unrelated navigation and stays a cache hit, because every page carries the root boundary. One anchored at /docs is discarded once you leave the docs section: applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip, which is the cheap side of that trade.

The router also never prefetches the page it is already on. Such a request cannot serve any later navigation (a same-URL click short-circuits) and only occupies one of the capped cache slots. It happens routinely, because a hover's intent timer can fire after the click it belongs to has already swapped, at which point the link under the cursor points at the current page. Both behaviours are internal and need no configuration.

Observing a degraded navigation (webjs:navigation-fallback)

Some conditions make a soft navigation impossible: the boundary-integrity scan finds no trustworthy shared segment, a click lands while the document is still parsing, or a cross-deploy build mismatch is detected. Rather than guess and risk a corrupt DOM, the router degrades to a full page load, which is bounded and correct but not soft.

Every such path dispatches webjs:navigation-fallback on document, in all environments including production, with detail { cause, href, willReload }. Causes are no-shared-boundary, live-boundaries-malformed, incoming-boundaries-malformed, readyState-loading, deploy-mismatch, deploy-mismatch-reload-suppressed, navigation-error-unrecoverable, and revalidation-discarded. willReload is false for a degradation that does not reload (a dropped background revalidation), so a listener can tell a click that became a document load from a background op that was skipped. The event is not cancelable: by the time it fires, degrading is the only safe option. In development a deduped console warning also prints.

document.addEventListener('webjs:navigation-fallback', (e) => {
  // A full document load on a click is a UX regression worth knowing about.
  if (e.detail.willReload) analytics.track('router_full_load', e.detail);
});

Per-segment loading skeletons

Each loading.{js,ts} in the route chain is rendered into a hidden <template id="wj-loading:<segment-path>"> at body end. On nav-start, the client clones the deepest matching template into the swap slot, so users see an instant per-segment skeleton during the fetch instead of stale content.

Concurrent navigations + cancellation

Each click / submit abort()s any in-flight fetch from the prior one (Turbo Drive's navigator.stop() pattern). Rapid clicks won't produce N parallel requests competing to be applied last. A monotonic nav-token additionally short-circuits any response that arrives after a newer navigation has settled, so a slow first request that races past its abort cannot revert the newer page.

Programmatic navigation

import { navigate } from '@webjsdev/core';

// Push history entry
await navigate('/about');

// Replace current history entry
await navigate('/login', { replace: true });

Opt-out per link / form

<a href="/logout" data-no-router>Log out</a>
<form action="/legacy" data-no-router>...</form>
<form action="/x"><button data-no-router>Full reload</button></form>

Use data-no-router for:

  • Auth flows: /logout, /auth/google, OAuth redirect chains. A full reload wipes in-memory module state (cached user data, auth tokens) that an SPA-style swap would leave behind.
  • Print views / embed pages: anywhere you want a clean-slate render without the existing layout.
  • Experimental routes backed by a different client runtime that needs a full boot.

Auto-skipped (no data-no-router needed)

  • Cross-origin hrefs.
  • Links with a download attribute, a target other than _self, or clicked with a modifier key (⌘/Ctrl/Shift/Alt).
  • Pure hash fragments on the same page (browser jumps to the anchor).
  • Hrefs whose path ends in a non-HTML extension: .pdf, .zip, .json, .xml, images, media, archives, documents.
  • Responses whose Content-Type isn't text/html.

Loading indicator

The router can expose a data-navigating attribute on <html> during navigation (deferred 150ms, so quick sub-150ms navs never trigger it) for a subtle progress indicator. It is opt-in: add data-webjs-nav-progress to your <html> element to enable it. It stays off by default because toggling an attribute on the root re-resolves oklch() and color-mix() token values on WebKit (so every iOS browser), repainting them for one frame. On a token-driven theme that shows as a visible flash on a slow nav. Enable it only when your theme does not lean on wide-gamut color tokens, or drive your indicator off the webjs:navigate event instead.

<html data-webjs-nav-progress> <!-- opt in once, in your root layout -->

html[data-navigating] {
  cursor: progress;
}
html[data-navigating]::after {
  content: '';
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 2px;
  background: var(--accent);
  animation: progress 1s ease-in-out infinite;
}

Listening for navigations

document.addEventListener('webjs:navigate', (e) => {
  console.log('Navigated to:', e.detail.url);
  // Track page view, update active nav indicator, etc.
});

Disabling the router entirely

import { disableClientRouter } from '@webjsdev/core/client-router';
disableClientRouter();

Next steps