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. Every <script> the swap brings in re-executes, whether it sits inside the swapped content or is a top-level node of the swapped range itself (a layout emitting its enhancement script as a sibling of ${children}). A parsed script node carries the HTML spec's "already started" flag, so the router replaces it with a fresh clone; that is what makes it run. The clone carries the page-load CSP nonce, not the nonce the response was rendered with. The one exception is a script INSIDE an element the swap preserved by identity through data-webjs-permanent, covered below.
  6. A webjs:navigate event fires on document with the final URL.

Write swapped scripts to be re-runnable. A script inside a swapped range runs again on every navigation that swaps that range, and giving it an id does not change that. The keyed differ reuses the live element, and the router still re-emits it. So a script that installs a listener or a MutationObserver should either be idempotent or guard on a flag it sets the first time. The alternative, running once and then never again, is the worse default: it is exactly what a progressive-enhancement script (a syntax highlighter, a chart initializer) must not do after a soft nav. Put anything that genuinely must run once in the root layout, whose markup is never swapped. data-webjs-permanent splits into two cases here. A script that IS the marked element is re-emitted like any other, so the attribute is not an escape hatch for a script itself. A script INSIDE a marked element that the swap actually preserved is left alone, because the attribute means that whole subtree survives as the same live node.

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 is served private so no shared cache can store it and serve it to a full-page navigation. It also carries Vary: X-Webjs-Have for caches that honour that header, but the guarantee does not depend on it: Cloudflare honours only Accept-Encoding. On a page that opted into caching via metadata.cacheControl, the fragment still carries an ETag, so the router's revalidating fetches stay cheap; on a default no-store page there is nothing to validate either way.

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, formtarget on a clicked <button>) take precedence over the form's own per HTML5, decided on whether the attribute is PRESENT rather than on its value being non-empty: formmethod="" really does submit as a GET, because a present-but-empty enumerated attribute falls to its own invalid-value default instead of inheriting the form's.

  • 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
  • A resolved target that is neither empty nor _self: iframes, popups, named windows. A submitter's formtarget wins over the form's whenever the attribute is PRESENT, so formtarget="" brings a target="_blank" form back to the current context and is routed, exactly as the browser would submit it.
  • 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,
      // Pure: the row is derived from the payload, nothing is minted here.
      // The .value getter re-folds the queue on EVERY read, so a minted id
      // would differ per render, and a hardcoded 'tmp' would collide across
      // two concurrent adds. The temp id is minted once in the handler.
      update: (state, add) => [...state, { id: add.tempId, title: add.title, pending: true }],
    });
  }
  async handleSubmit(e) {
    const title = e.target.querySelector('input').value;
    const tempId = crypto.randomUUID();
    const promise = createTodo({ title });
    this.optTodos.add({ tempId, 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>`)}</ul>`;
  }
}

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.
  • The attribute is SUBTREE-scoped, so a <script> inside a preserved element is NOT re-emitted and does not run again. That is the point: re-running a widget's init script against the live instance you asked the router to keep is a double-initialization, not a refresh.
  • The script exemption applies only once the element has ACTUALLY been preserved. The first time a permanent element arrives there is nothing to preserve (the both-exist rule above), so it is ordinary new content and its scripts run like any other. An element with no id can never be preserved, so it never gets the exemption either.
  • A script that IS the marked element is always re-emitted. The attribute preserves accumulated JS state, and a script's only state is that it ran; exempting it would leave a script that runs on a cold load and never on a soft navigation.

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.

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another page navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. Absent either of those it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone while the offset is out of reach, rather than freezing the clamp, and chases the saved offset, re-asserting it once the page is tall enough to hold it and protecting it from there. That is the only scroll the router writes after the initial restore. It stops on the first real input, so it never fights a reader who has started scrolling, and it is time-boxed to a few hundred milliseconds measured from the restore, which is tighter than the window a landed restore gets; the suppression it installs on landing shares that deadline rather than starting a fresh one. The bound is what protects a reader who has landed and started reading, since they generate no input to cancel it. Anchoring is left on only while the offset is out of reach, since that is what heals the clamp; once the chase lands on the offset it suppresses anchoring too, because the growth that made the offset reachable is rarely all of it. Both end together on the bound, after which the router writes no more scroll and anchoring is back on, so a component reaching its final height later has its growth added and the reader drifts below the offset rather than sitting at the clamp.

A frame-targeted navigation or submission is the one exception to the closing rule above: it swaps a single <webjs-frame> rather than the page, so the restored offset is still the right one and the restore is left running. Frame targeting here means what it means everywhere else, so a trigger that breaks out with data-webjs-frame="_top", or names an id that does not resolve, is a page navigation and does close the window.

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, revalidation-discarded, and pre-boot-navigation. 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);
});

pre-boot-navigation is the one cause reported about a load rather than during one. The boot is a module script, which the HTML spec defers until parsing finishes, while the links it will intercept are clickable from first paint. A click inside that window is a plain browser navigation, and the arriving document reports it with willReload: false because the load already happened. On a warm cache the window is a few tens of milliseconds; on a first visit over a slow connection it is long enough to matter, which is why the runtime is hinted in the head with <link rel="modulepreload"> rather than discovered a round trip later.

Read it as a rate, not as a verdict on any single event. The check knows only that this document arrived by a same-origin navigation that was not a soft nav, so a data-no-router link, a target="_blank" open, a cross-document form post, and an app that turned the client router off all land here too. A reload, a back/forward restore, an external entry, and a full load the router itself chose are excluded. The report comes from the router's own boot, so a page that ships no client runtime (a fully elided, display-only route) reports nothing, which is also a page with no router that a click could have outrun.

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