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:
trueor"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 loadsreact-server-domon 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.