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 DECIDES which of two tiers to apply, with Next.js parity. Nothing is applied yet: a boundary whose route-key CHANGED will be 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) will MORPH 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 URL updates via pushState, and this happens before the incoming content replaces what is on screen, not after. The order is deliberate: the entry for the page you are leaving is finalized while that page is still the live document, rather than against the content that replaced it. This is Turbo Drive's ordering too. (It was introduced as a fix for the blank iOS back-swipe preview, on the theory that WebKit binds the gesture snapshot when the entry is recorded. That theory turned out to be wrong, and the preview was actually fixed by leaving history.scrollRestoration alone; the ordering is kept because recording an entry against its own page is correct regardless.) One exception worth knowing: if a loading.{js,ts} skeleton is showing (it is applied optimistically, before the response arrives), the entry is recorded against that skeleton rather than the page you left.
  4. The <head> is add-only merged (preserves runtime-injected styles like Tailwind's), still before the content itself changes.
  5. The swap runs, applying the tier chosen in step 2. 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.
  6. <script> tags re-execute and custom elements upgrade. That re-execution reaches a script wherever it sits, inside the swapped content or as 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.
  7. 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 advances the URL and swaps the shell (with the fallbacks) in immediately, 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. To skip the wait rather than style it, see Optimistic UI.

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.

A frame swap never scrolls the page

A page navigation scrolls to top, the way a browser does. A frame swap does not: it changes one region and leaves the rest of the document standing, the reader's scroll offset included. Without that, filtering a panel below the fold would throw the reader back to the top of the page, with the panel they just clicked in off screen. The rule covers every way a frame swaps, a nested link, an external data-webjs-frame trigger, a frame-targeted form submission, and a src self-load, and it covers a #hash on a frame link too, which rides the URL without moving the viewport.

One thing this rule does NOT cover, because the router never sees it: a pure fragment link whose path and query match the page it sits on. The click handler bows out before preventDefault, so the browser performs its own native fragment jump and the window does move.

Every spelling of a fragment link is the browser's, the bare # included. href="#" is the back-to-top idiom, and it serializes with an empty fragment that reads identically to no fragment at all through URL.hash, so the bow-out tests the href for a # instead. A <a href="#">Back to top</a> therefore scrolls to top natively, inside a frame as well as outside one. href="" is not a fragment link at all: it resolves to the current url with the fragment removed, which the spec reloads rather than jumps, so the router navigates it like any other link.

Read "never scrolls" as "WebJs never writes a scroll", not as a promise the viewport cannot move. A swap that makes the panel shorter shortens the document with it, and a reader parked near the bottom is then holding an offset the document can no longer reach, so the browser clamps it. On the gallery's frames demo, filtering from All to Done at the bottom of the page moves the window from 474 to 405, exactly the 69px the document lost. The router writes no scroll there, and any DOM change that shortens a page does the same. Keeping the frame a stable height across its states avoids it.

The escapes are page navigations and DO scroll to top: data-webjs-frame="_top", and an id that cannot be matched to a live frame, which warns and degrades to a normal navigation. Do not read that second one as covering a response that lacks the requested frame (the webjs:frame-missing warning): there the frame resolved and the navigation stayed frame-scoped, so the offset holds and only the panel is left unchanged. Turbo's autoscroll opt-in, which scrolls the frame itself into view on swap, has no WebJs equivalent; the router simply never writes scroll for a frame.

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.

The popstate an in-page fragment click produces is the exception, and it restores nothing. The browser has already done the jump, so the router absorbs it and leaves live DOM identity and hydrated component state alone. That covers a repeat click of one anchor too, which replaces its history entry rather than pushing a new one and still fires popstate with the URL unchanged.

The gate is which popstate the router caused, not how the URL looks: it marks the click it bowed out of and the next popstate consumes that mark. A Back between two entries differing only by fragment can still need a re-render, because a form's raw action attribute carries no fragment, so a bound-submitter form declaring action="/p" pushes its 422 re-render at /p while the reader sits at /p#sec. So an ordinary Back or Forward between two fragment states still re-renders.

A back/forward restore reserves the page's recorded HEIGHT across the swap, then suppresses the browser's scroll anchoring (overflow-anchor) for the restore's duration, then puts both back. The reservation is what makes the saved offset reachable: the snapshot's markup is briefly shorter than the page it was serialized from, because its components have not rendered yet, so without it the browser clamps the restore to whatever the short document allowed and the reader lands short. The suppression covers the other half: content still SHIFTS above the viewport as those components render, and anchoring would add that shift to the offset just replayed, landing the reader 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. 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. Suppression only withholds a browser correction, it never moves the viewport. The height reservation releases on the same settle and ceiling, and on a superseding navigation, but deliberately NOT on user input: releasing the page's height under a reader mid-scroll is the one harm an early release could do. If your app sets overflow-anchor or an inline min-height on the root itself, each is saved and put back.

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.

The browser restores Back/Forward scroll, not the router. WebJs FORCES history.scrollRestoration to auto when the router starts, overriding an app that had set 'manual' (and putting that value back if you call disableClientRouter()), so setting it to 'manual' yourself does not take effect while the router is running. Only under auto does the browser record a scroll position per history entry, replay it on a traverse, and compose the iOS edge back-swipe gesture preview from that recording. The router writes no scroll of its own on a restore: it reserves the recorded height across the swap so the browser's replay lands on a document that can hold the offset, and that is the entire mechanism. One writer, which is the model Next and Remix 3 use as well. Taking manual control suppresses the recording, so every scrolled page previews blank for the whole duration of the gesture; that is a real bug WebJs shipped, inherited from Turbo Drive's assumeControlOfScrollRestoration, and it is why Turbo still has it (Turbo is single-writer too, but its writer is the app rather than the browser).

Navigation never animates the scroll, so setting html { scroll-behavior: smooth } in your app does not make it do so. The forward-nav scroll-to-top is the router's own write and is forced behavior: 'instant'; the back/forward restore is the browser's and is not a scrolling-API call at all, so scroll-behavior cannot reach it either. 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.

Preserving scroll on a forward navigation (data-preserve-scroll)

A forward navigation scrolls to the top, the way a browser does. data-preserve-scroll is the per-link escape hatch, for a navigation that changes only part of what the reader is looking at: a filter, sort, or tab link whose control sits below the fold, a pager, or a form that re-renders in place with validation errors. WebJs wants it more than most frameworks do, because a searchParams-only navigation already morphs the deepest shared boundary and keeps the hydrated state of every component around it, so the scroll is the only thing such a navigation still throws away.

<!-- one link -->
<a href="?sort=new" data-preserve-scroll>Newest</a>

<!-- or a whole region, resolved with closest() -->
<nav data-preserve-scroll>
  <a href="?sort=new">Newest</a>
  <a href="?sort=top">Top</a>
  <a href="/" data-preserve-scroll="false">Home</a>  <!-- opts back out -->
</nav>

<!-- forms too: resolved from the submitter, falling back to the form itself -->
<form method="post" action="${saveDraft}" data-preserve-scroll>...</form>

The attribute resolves through closest(), so one mark on a wrapping element covers every link inside it, and data-preserve-scroll="false" on something nearer opts back out. On a form the lookup starts at the submitter and falls back to the form, so a marked form covers its buttons even when one is attached from elsewhere with form="id" rather than nested inside it. A hash link still scrolls to its anchor, because the reader named a target and a named target beats a blanket preference. It is inert on a frame-targeted link, since a frame swap never writes a scroll to begin with, and inert with JS off, where the link is a plain <a>, so nothing about a page's correctness may depend on it.

It carries the reader's current offset onto the destination. It does not restore the offset they once had there, which is a different feature and not one WebJs ships, so this is the wrong tool for a "back to the list" link.

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.

Frame links are prefetched too, in their own dimension

A link that drives a <webjs-frame> is prefetched with the same X-Webjs-Frame header its click will send, so the warm entry is the frame subtree the swap actually needs and the click is instant. Without this the hover cost a duplicate request and bought nothing, because the click needs a different response than a page-level prefetch holds.

Since the server varies that response on the request header, the cache keys an entry by URL plus frame id. A prefetched page fragment is therefore never applied into a frame region, nor a frame subtree into a full-page swap, and both can be cached for one URL at once. A frame entry is validated by its frame still being in the document rather than by a boundary anchor, because a subtree carries no boundary comment. A framed link pointing at the URL you are already on is not prefetched at all: that is a frame refresh, and a refresh must show fresh bytes. A <webjs-frame src> that loads itself stands outside this cache entirely, neither reading it nor keeping an entry it supersedes, for the same reason.

A route that streams does not get this. The server slices the subtree only when the render did not stream, so a route with a loading.{js,ts} or a Suspense boundary answers every framed request with the whole document instead. The router will not cache that under a frame key. The swap looks for the frame inside the response body, and on a streamed page it may not be there yet: content still inside a pending boundary arrives in a <template> the swap does not descend into. Consuming such an entry would then leave the region unchanged and log a warning, while the navigation around it still completes: the URL advances, though the scroll offset is left alone as on any frame nav, so the reader gets a changed address over an unchanged panel. A frame sitting outside every boundary does arrive in the first flush and would be found, but the router cannot tell the two cases apart from the bytes, so it declines the body either way. On those routes the click still costs its round trip, exactly as before. The refusal is remembered, so the link re-asks about once per cache TTL rather than on every hover (the memo set is itself small and capped, so a page with many distinct refused frame links can re-ask sooner).

One thing to expect in the network tab: because dedupe is per dimension, a page that links the same URL twice, once driving a frame and once not, warms both and issues two speculative requests where it previously issued one. The two responses genuinely differ and a click on either link needs its own, so collapsing them would leave one of the links unwarmed ahead of the click. Both stay inside the same cache cap, concurrency gate, TTL, and Save-Data gate as every other prefetch, and no new prefetch trigger is added.

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 });

// Keep the reader's scroll offset (the twin of data-preserve-scroll)
await navigate('/products?sort=new', { scroll: false });

Refreshing the page you are on

refreshPage() re-renders the current URL on the server and applies it in place, with no page load. It records no history entry and never scrolls, so the reader keeps their place and Back still goes to the previous page.

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

// 'page' (the default): morph the deepest shared boundary
await refreshPage();

// 'shell': replace the whole body, for when the LAYOUT's own markup changed
await refreshPage('shell');

The two modes differ in what survives. 'page' morphs the deepest shared boundary, so the outer layout's DOM and the hydrated state of its components are preserved. 'shell' replaces the whole body, which is what a layout change needs: a layout's own header, nav, and footer live outside every children range, so a boundary morph would leave them untouched. Component instances do not survive a 'shell' refresh.

A refresh sends no X-Webjs-Have header, deliberately. The server short-circuits at the first layout the client already holds, and a same-URL request matches every one of them, so the response would omit the very layout that changed. It resolves false when it did not apply (the router is disabled, or the fetch failed), so the caller can fall back to a full load.

It does not reload changed component modules, and cannot: customElements.define is once-per-tag and a module URL is fetched once per document. That is exactly why the dev server calls refreshPage() for a page or layout edit and falls back to a full reload for a component edit. See Runtime for which dev modes get the in-place refresh.

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.

To keep the router but skip its scroll-to-top, the attribute is data-preserve-scroll rather than this one. See Preserving scroll on a forward navigation above.

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