SPA Router Guide
Coralite's default navigation model is multi-page: every route is a fully pre-rendered static HTML document, and transitions between full page loads are animated with native View Transitions. That model is the right default for content sites — it is simple, SEO-complete, and works behind any static host.
The opt-in router plugin (coralite/plugins/router) turns an application into a true single-page app on top of that same static output: it intercepts same-origin link clicks and history navigation, fetches the target route's pre-built HTML, and swaps only the outlet region in the browser. Nothing you see on the server changes — every route is still a real file. The router is a client-side layer over the static site.
When to Reach for the Router #
Choose the router when your application has state or resources that must survive navigation:
- Audio or video playback that keeps running as the user moves between screens.
- Long-lived connections: WebSockets, Server-Sent Events, canvas or WebGL contexts.
- App-shell experiences — a single login session, a wizard, or a dashboard where full page reloads would be jarring.
- Client-side state (search filters, drafts, UI settings) that must follow the user across routes.
If none of those apply, stay with the multi-page default. The router adds real behavioral surface area — guards, fetch/swap semantics, outlet requirements — that your content site does not need.
Registering the Router #
The router is a standard plugin. Register it in coralite.config.js:
import { defineConfig } from 'coralite'
import router from 'coralite/plugins/router'
export default defineConfig({
output: 'dist',
components: 'src/components',
pages: 'src/pages',
plugins: [
router({
base: '/',
outlet: 'data-router-outlet',
autoWrap: true,
outletOnly: false
})
]
})
outlet(String, default'data-router-outlet') — the attribute name that marks the SPA swap region. Rename it here and use the new attribute in your pages.autoWrap(Boolean, defaulttrue) — whenfalse, pages without an outlet are rendered exactly as authored instead of being wrapped in a<router-outlet>. Token rendering is unchanged. Fetched SPA routes must still contain an outlet.outletOnly(Boolean, defaultfalse) — whentrue, link interception is skipped on pages that have no outlet, leaving navigation to the browser. This is the safe pairing withautoWrap: falsefor routes that should stay outside the SPA shell.base(String, default'/') — declared in the plugin's config schema, but it currently carries no behavioral wiring: routing performs no base-path stripping or prefixing. Do not rely on a non-'/'base today.
Everything the router does — link interception, popstate handling, guards, head reconciliation — lives in the plugin's lifecycle hooks, which only run once the plugin is registered. A data-router-outlet marker on a page without the plugin is inert: the page behaves as a standard multi-page document.
The Outlet & Persistent Elements #
On every client-side navigation, the router replaces only the outlet's children. Think in terms of two zones:
- Inside the outlet — route content. Replaced on each navigation, re-hydrated from that route's pre-built HTML.
- Outside the outlet — the app shell. Navbar, footer, background audio, open sockets. Never touched by the router; their component instances and state persist across every route change.
The typical app shell is authored on every page, marking the routed region with the outlet attribute (each route is its own pre-built document — the router's job is only to keep the shell's DOM nodes alive across swaps):
<!-- src/pages/playback/index.html -->
<body>
<coralite-navbar></coralite-navbar>
<!-- Persistent: survives every route swap -->
<site-player src="/media/keynote.mp3"></site-player>
<!-- Swapped: each navigation replaces this region only -->
<main data-router-outlet="">
<h1 class="display">Keynotes</h1>
<p>Pick a keynote to start playback.</p>
</main>
<coralite-footer></coralite-footer>
</body>
A persistent element behaves like any other component — here, an audio player whose media keeps playing while routes swap beneath it:
<!-- components/site-player.html -->
<template id="site-player">
<audio ref="track" loop=""></audio>
<button ref="play" type="button" aria-label="Play or pause">▶</button>
</template>
<script type="module">
export const attributes = {
src: { type: String }
}
export async function client ({ refs, state, signal }) {
const track = refs('track')
track.src = state.src
refs('play').addEventListener('click', () => {
if (track.paused) {
track.play()
refs('play').textContent = '❚❚'
} else {
track.pause()
refs('play').textContent = '▶'
}
}, { signal })
}
</script>
Automatic wrapping
Pages that declare no outlet of their own are automatically wrapped at render time: when the router is active, the plugin injects a <router-outlet data-router-outlet> element around the page's entire <body> content. This is convenient, but it means the whole body is the swap region — including your navbar and player, if they are on that page. If a route declares no explicit outlet, nothing on that route persists.
Pages that already contain an outlet are never double-wrapped. Outlet detection also looks inside the templates of components a page references, so an outlet living in a shared layout component satisfies the page and suppresses the wrap. The plugin registers a virtual <router-outlet> component (<slot></slot> body), so:
<!-- A layout component can own the outlet -->
<router-outlet>
<h1 data-coralite-page-token="00d845bd-0"> </h1>
<slot></slot>
</router-outlet>
Opting out of automatic wrapping: set autoWrap: false when some pages must render without a wrapper, for example a standalone checkout or a full-page third-party embed. With that flag, the page renders exactly as authored and tokens resolve normally. Because client navigation still requires an outlet in the fetched document, pair it with outletOnly: true so links on outlet-less pages use native browser navigation instead of being intercepted and failing.
router({
autoWrap: false,
outletOnly: true
})
Important: router.navigate() updates the history entry before fetching and checking for an outlet. Calling it from a page without an outlet changes the URL and then rejects. Prefer plain links on outlet-less pages, or keep outletOnly: true so the click guard never hands those pages to the router.
Programmatic Navigation #
The router API is injected into component and page client() blocks under the router key (the same object is also on window.__coralite__.plugins.router):
router.navigate(path, { replace = false })— client-side navigation through the full pipeline: guards, thecoralite:navigatingevent, history update, fetch, head reconcile, outlet swap, re-hydration, scroll, and thecoralite:navigatedevent. Withreplace: truethe current history entry is replaced instead of pushed.router.current()— returns{ path }, the current browser pathname.router.beforeEach(guard)— registers a guard; returns an unregister function.
export function client ({ refs, router, signal }) {
refs('back').addEventListener('click', () => {
history.back()
}, { signal })
refs('dashboard').addEventListener('click', () => {
router.navigate('/app/dashboard')
}, { signal })
refs('search').addEventListener('click', () => {
// Replace instead of push: no dead-end in the back button
router.navigate('/search?term=coralite', { replace: true })
}, { signal })
}
Every link inside the app or the outlet is intercepted the same way, so router.navigate() and a plain <a href> click produce identical behavior — guards, events, and history semantics included. That makes it rare to need programmatic navigation at all. With outletOnly: true, clicks are left to the browser whenever the current document has no outlet; programmatic router.navigate() calls are not affected by that guard.
Navigation Guards #
Guards run in registration order before every client navigation. They receive ({ to: { path }, from: { path }, signal }) and may return a value to steer (or stop) the navigation:
undefinedortrue— proceed.false— cancel. No history update, no fetch; the user stays put.- A path string (e.g.
'/login') — a soft redirect: the router re-navigates client-side withreplace: true. Relative strings are resolved against the current URL. Redirect chains are capped at a depth of 10; deeper chains abort (the dev runtime logsRedirect loop detected). - An
http:///https://URL — a hard redirect viawindow.location.replace(): a full document transition, useful for bouncing to an external IdP or SSO host.
A guard may be async (e.g. an auth check against an API). A guard that throws cancels the navigation (an AbortError from an aborted navigation unwinds silently). Every running navigation exposes an AbortSignal to its guards and its fetch: a newer navigation aborts the in-flight one, so long-running guards should honor the signal before acting on their result.
Where to Register Guards
The guard list lives in the router's client-init scope, so it persists for the whole SPA session — an outlet swap does not reset it. A guard re-registered from a component's client() therefore accumulates on every visit to a route that contains that component. Two patterns keep this honest:
- App-wide guards belong to a persistent shell component — one that lives outside the outlet and therefore connects exactly once per document load.
- Route-scoped guards register in the route's own component and unregister when that route's instance is torn down (its
signalaborts).
export function client ({ router }) {
// This component sits outside the outlet, so client() runs once per
// document load — guard registered exactly once for the SPA session.
router.beforeEach(({ to }) => {
if (to.path.startsWith('/admin') && !isAuthenticated()) {
return false // cancel: stay in the app you are logged in to
}
return true
})
}
export function client ({ router, signal }) {
const unregister = router.beforeEach(({ to }) => {
if (to.path.startsWith('/app') && !isAuthenticated()) {
return '/login' // soft redirect
}
// e.g. bounce an expired session to the external IdP
if (sessionExpired()) {
return 'https://ids.example.com/mfa' // hard redirect, full document load
}
return true
})
// This route leaves the outlet: unregister its guard with it.
signal.addEventListener('abort', unregister)
}
Navigation Events #
The router also speaks plain DOM events on window, which keeps integrations (analytics, third-party widgets, feature flags) loose-coupled from the router API.
coralite:navigating— dispatched before the target is fetched. Cancelable: callevent.preventDefault()to abort.event.detailis{ from, to }.coralite:navigated— dispatched after the swap, upgrade, and scroll settle. Not cancelable.event.detailis{ from, to }.
export function client ({ refs, signal }) {
let dirty = false
window.addEventListener('coralite:navigating', (event) => {
if (dirty) {
event.preventDefault()
}
}, { signal })
window.addEventListener('coralite:navigated', (event) => {
const { from, to } = event.detail
trackPageView(to, { referrer: from })
}, { signal })
refs('save').addEventListener('click', async () => {
dirty = false
await saveDraft()
}, { signal })
}
Note the ordering: guards run first (before any event, and they can hard-stop or full-redirect), then coralite:navigating, then the fetch. Guards are the right tool for navigation policy — auth, redirects, hard blocks. The cancelable event is the right tool for view-local concerns like unsaved form state, or for integrations that should not need to know the router API exists.
Link Interception & Opt-Outs #
While the router is active, left-clicks on same-origin <a href> links become client-side navigations. The router deliberately falls back to native browser behavior in these cases:
- The click is not the primary button, or carries
meta/ctrl/shift/alt(opens tabs, opens in background, context menus). - Another listener already called
preventDefault(). - The link has a
targetother than_self(e.g._blank). - The link carries a
downloadattribute. - The link is cross-origin.
- The link carries
data-router-ignore— the per-link, per-URL opt-out you author in markup.
<a href="/files/report.pdf" download="">Download report</a>
<a href="/legacy/checkout" target="_blank">Open legacy checkout</a>
<a href="/reports/export.csv" data-router-ignore="">Force full reload</a>
State Across Route Swaps #
This is the section that pays for adopting the router. There are exactly two places state can live, and the distinction drives how you design your app:
1. Component state — route-scoped
A component's reactive state lives on its instance. When a navigation swaps the outlet, the old instances are disconnected (their signal fires, listeners and observe() subscriptions clean up) and the new route's components are hydrated from that route's pre-built HTML — the router replaces the shared hydration store with the fetched document's <script data-coralite-hydration> payload before upgrading the outlet. Consequence: if you change state on /cart, leave, and come back, you are looking at the route's server-rendered state again. Treat state as per-screen, and promote anything that must outlive the screen.
2. The global store — app-scoped, survives navigation
Coralite's signal-backed Global Store is a single client runtime instance created from your coralite.config.js. The router touches the hydration store, not the store object — so store values follow the user across every swap, and any getter or client() reading them reactively updates the moment they change:
import { defineConfig } from 'coralite'
export default defineConfig({
output: 'dist',
components: 'src/components',
pages: 'src/pages',
store: {
user: null, // set client-side after the session check
volume: 0.8,
draft: null
}
})
<!-- components/volume-dial.html -->
<template id="volume-dial">
<div ref="dial"></div>
<span data-coralite-page-token="00d845bd-1"> </span>
</template>
<script type="module">
export const getters = {
label: ({ store }) => `Volume ${Math.round(store.volume * 100)}%`
}
export function client ({ refs, store }) {
const dial = createVolumeDial(refs('dial'))
dial.onInput = (value) => {
// reactive: every dial on every route updates
store.volume = value
}
}
</script>
- Read/write the store via the
storecontext key ingettersandclient(). Mutations fromserver()during SSR are forbidden — the store is a read-only snapshot at build time. - Use
store.batch(fn)for multi-key writes (subscribers fire once) andstore.subscribe(key, cb)for side effects. See the Lists & Store reference. store.reset()restores the config defaults — a clean log out path in an SPA.
Tearing Down Imperative Resources
Components that hold native resources (a map instance, a WebGL context, a worker) should release them when their instance dies. The component's signal aborts on disconnection, so hook cleanup to it:
export function client ({ root, signal }) {
const map = createMap(root.querySelector('.map-canvas'))
signal.addEventListener('abort', () => {
map.dispose()
})
}
Scroll & Hash Behavior #
After each client-side navigation the router scrolls the window to the top — unless the target path contains a # fragment, in which case the scroll is left alone (use element.scrollIntoView() or the browser's native anchor jump if you need a specific fragment). Back/forward navigation works through the normal popstate entry: the router re-fetches and re-renders the route's canonical pre-built document for the new history entry, so in-memory browser state is never the source of truth.
Production Pitfalls #
- Every routed document must contain an outlet. A navigation to a route whose served HTML has no outlet fails with an error. Regular pages get one via auto-wrap, but server-level fallbacks (a static host's
404.html, an edge-rewritten error page) do not, and pages built withautoWrap: falseonly get one if authored explicitly. Ship a 404/fallback page built by Coralite so it carries an outlet like every other route, or keep outlet-less routes outside the SPA withoutletOnly: true. - Do not call
router.navigate()from an outlet-less page. History is updated before the target is fetched and the outlet is checked. If no outlet exists, the promise rejects after the URL has already changed. Use a plain<a href>link, or configureautoWrap: falsewithoutletOnly: true, so those pages stay in the native multi-page flow. - Only
<title>and<meta name|property>are reconciled in the head. The fetched page's<link>and<script>tags are never injected into the live document — its CSS must already be loaded (it is, since builds emit global per-project style bundles) and any third-party per-page scripts will not run on SPA navigation. Keep per-route JS in componentclient()blocks instead. - The
X-Coralite-Router: trueheader distinguishes route fetches from initial page loads. Your host, CDN rule, or service worker must serve route documents (not a redirect to the content root, not a cached SPA shell) for that header. If caching is involved, verify a route fetch returns that route's HTML. - Third-party singletons inside the outlet silently go stale. Libraries that cache a DOM reference or bind to
windowglobals during their first mount need to reinitialize after each swap — listen forcoralite:navigatedand rebind, or keep the singleton outside the outlet. - Rapid navigation is safe, guards are not automatically so. A newer navigation aborts the in-flight one (its guard and fetch receive the abort — honor the
signal). A guard that does an unbounded fetch and resolves after the abort wins nothing: checksignal.abortedbefore acting on its result. - Do not store auth secrets in the store and treat the store as UI/session state.
store.reset()resets to config defaults — anything that must be cleared (tokens) belongs inlocalStoragewith an explicit log-out flow. viewTransitions: trueanimates outlet swaps as native View Transitions (skipped underprefers-reduced-motion: reduce). The same flag also crossfades full page loads for the multi-page routes you keep outside the router — a single switch for both behaviors.