Client Router: the opt-in SPA layer

Off by default — turn it on for app-like surfaces that need in-memory state to survive navigation. Soft swaps over the same HTML the server already serves, with <Island persist> for websockets.

The feature#

Navigation is the floor: the browser navigates, June ships no router. But some surfaces — a dashboard, a builder, anything with a live connection — need in-memory state to survive a navigation, not reset on every click. For those, flip one switch:

// june.config.ts
export default defineJune({ clientRouter: true });

Now same-origin link clicks become soft swaps: June fetches the next page — the same complete HTML document the server already serves, no special payload format — replaces the page region, re-hydrates that page's islands, and animates the swap with View Transitions. History (pushState/back-forward) just works. There is no client route table and no Flight payload: the full-HTML-per-URL contract is the navigation transport.

Two appliers: morph and Flight#

clientRouter picks how a soft navigation applies the next page:

  • true or "morph": fetches the next URL's HTML fragment and morphs it into the page. Islands are treated as opaque, so their state survives.
  • "flight" (not active yet): asks for the next URL as a React Flight payload and renders it into one persistent React root, so React reconciles page to page. The client half is written, but the server doesn't render a flight projection yet. The request negotiates away and the page hard-navigates, so setting it is safe, but today it behaves like no client router. It loads react-server-dom on the client only when chosen.

Flight is reachable only by naming "flight", so the common clientRouter: true never becomes Flight silently.

Persist a live island#

Because June composes layouts into the page itself, a normal swap would tear down everything — including an open websocket. Mark an island persist and the router carries its live node (React state, open connections and all) across the navigation instead of re-creating it:

// app/dashboard/layout.tsx
import { Island } from "@junejs/core/islands";
import { LiveFeed } from "./LiveFeed";

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Island name="LiveFeed" component={LiveFeed} persist />  {/* socket stays open */}
      {children}
    </>
  );
}

How it survives rests on two platform facts. React forbids outside code from mutating DOM it owns, so the morph treats every island as opaque — it reconciles the document around the islands and never diffs into their interior. And the persisted island's live node is relocated with moveBefore(), a same-document state-preserving move that keeps its React state, focus, and open connections intact. That same-document boundary is exactly why this is a Tier 1 (soft-swap) capability: a real cross-document navigation tears the old document down, so there is no live node to carry — which is what the floor (Navigation) trades away for zero JS.

Pure progressive enhancement#

The router only ever adds behavior on top of pages that already work:

  • It degrades. No JS, a failed fetch, or a response it doesn't recognize all fall back to a normal browser navigation — never a broken page.
  • It's race-safe. A navigation-generation token discards a stale response when a newer navigation overtakes it (the classic click-then-back bug).
  • The agent surface is untouched. Every URL is still a complete, projectable document — .md, .json, and /mcp (MCP) are exactly what they were. The router lives only on the human surface.

Off by default. This very site keeps it off — it's documents, so the browser navigates and we ship ~1KB of rules, no router.

Why it matters#

The real question was never "does June have an SPA mode" — it's "do you want to carry a client router." For most sites the answer is no, and the Standards floor already feels instant. For the app-like minority that needs state to outlive a navigation, the router is there as one config line — and even then it's an enhancement you can remove, not a runtime your whole site now depends on.