---
title: "Client Router: the opt-in SPA layer"
nav: "Client Router"
description: 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.
date: 2026-06-14
section: Features
order: "24"
---
## The feature

[Navigation](/docs/features-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:

```ts
// 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](/docs/features-islands), and animates the swap with View
Transitions. History (`pushState`/back-forward) just works. There is no
client route table and no [Flight payload](/docs/features-rsc): 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:

```tsx
// 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](/docs/features-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](/docs/features-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](/docs/features-navigation).

## 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](/docs/features-web-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.
