Router Plugin
The router plugin (coralite/plugins/router) is an opt-in single-page application router layered on Coralite's static output. Without it, navigation is the default progressive-enhanced model: plain <a href> links, static HTML per route, and native Same-Document View Transitions between full page loads. When registered, the plugin intercepts same-origin link clicks and browser-history navigations, fetches the target's pre-built static HTML, and swaps only the outlet region (and head tags) in the live browser — with no full reload, and no client-side routing of arbitrary client state.
Registration (Required) #
Every SPA behavior of the router — link interception, popstate handling, guards, head reconciliation — lives inside the plugin's client:init and server:onBeforePageRender hooks, which only execute when the plugin is registered. A data-router-outlet marker without plugin registration is inert: the page behaves as a standard multi-page app.
import { defineConfig } from 'coralite'
import router from 'coralite/plugins/router'
export default defineConfig({
plugins: [
router({
base: '/', // String, default '/'
outlet: 'data-router-outlet', // String, default 'data-router-outlet'
autoWrap: true, // Boolean, default true
outletOnly: false // Boolean, default false
})
]
})
Invalid config types throw at plugin instantiation (e.g. Invalid config value for "base").
Configuration Options #
| Option | Type | Default | Description |
|---|---|---|---|
base |
String | '/' |
Declared base path for the router. Declared in the plugin's config schema but currently carries no behavioral wiring in router.js; routing does not perform base-path stripping or prefixing, so a non-'/' base is not honored — do not rely on it today. |
outlet |
String | 'data-router-outlet' |
The attribute name that marks the SPA swap region. Any element carrying this attribute — or the reserved <router-outlet> tag — is an outlet. |
autoWrap |
Boolean | true |
When false, the server-side render pipeline does not automatically wrap outlet-less pages in a <router-outlet>. The page renders exactly as authored. Fetched documents must still contain an outlet for client-side navigation to succeed. |
outletOnly |
Boolean | false |
When true, link interception is skipped whenever the current document has no outlet at click time. The click falls through to the browser's native navigation. This guard is evaluated before link detection and does not affect router.navigate(). |
Outlet & Swap Region #
- Any element bearing the configured outlet attribute (
[data-router-outlet]by default) — or the reserved<router-outlet>tag name — is an outlet. The plugin registers a virtual<router-outlet>component (virtual:router-outlet/outlet.html, body<slot></slot>), so the outlet can be authored directly on a page or inside a layout component. Outlet detection also looks inside component templates, so an outlet living in a layout component satisfies the page and suppresses the automatic wrap. - On client navigation, only the outlet's children are replaced. Elements outside the outlet — persistent navbars and footers, background audio players, live WebSockets — are never touched by the router.
- Pages that declare no outlet 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. Pages that already contain an outlet are never double-wrapped. - Set
autoWrap: falseto disable the automatic body wrap. With this flag, outlet-less pages render exactly as authored and still resolve page tokens normally. Link interception remains enabled unlessoutletOnly: trueis also set; a fetched document without an outlet still fails the navigation. - Set
outletOnly: trueto make link interception conditional on the live document containing an outlet. This is useful when some routes are intentionally outside the SPA shell: on an outlet-less page, links navigate normally instead of failing inside the router. - Both the live document and every fetched document must contain an outlet for a client navigation to succeed; a navigation whose fetched document has none fails with an error.
navigate()updates history before fetching, so do not call it from a page that has no outlet unless you handle the resulting rejected navigation.
Client Router API #
The router API is injected into component and page client() blocks under the router key — client({ router }) — and is also exposed on window.__coralite__.plugins.router:
-
router.navigate(path, { replace = false })— performs a client-side navigation: runs guards and thecoralite:navigatingevent, updates the history entry (pushStatewith state{ coralite: true }, orreplaceStatewhenreplace: true), fetches the target path as an HTML document with theX-Coralite-Router: trueheader, reconciles the head, swaps the outlet, re-hydrates the new outlet content, scrolls (unless the path contains a#), and dispatchescoralite:navigated. The returned promise resolves after the swap and upgrade settle, and rejects if the fetch returns a non-OK status or no outlet is found. -
router.current()— returns{ path }, the current browser pathname. -
router.beforeEach(guard)— registers a navigation guard and returns an unregister function that removes it. Guards receive({ to: { path }, from: { path }, signal })and may be asynchronous. See Navigation Guards. -
router.viewTransitions— read-only getter. Mirrors the project-levelviewTransitionsflag from the hydration payload. When true, and the browser supportsdocument.startViewTransitionwithprefers-reduced-motionunset, the outlet swap runs inside a native View Transition.
export function client ({ refs, signal, router }) {
refs('go').addEventListener('click', () => {
// Push a new history entry
router.navigate('/app/dashboard')
}, { signal })
}
Navigation Guards #
Guards run in registration order before every client navigation. The return value determines what happens next:
| Return value | Effect |
|---|---|
undefined or true |
Allow the navigation to proceed. |
false |
Cancel the navigation (no history update, no fetch). |
Relative path string (e.g. '/login') |
Soft redirect: the router re-navigates client-side with replace: true. Nested guard redirects are capped at a depth of 10; deeper chains are aborted (the dev runtime logs Redirect loop detected). |
Absolute URL string (http://… / https://…) |
Hard redirect via window.location.replace(url) — a full document transition to an external (or same-origin) page. |
A guard that throws (other than an AbortError) is logged in dev runtimes and cancels the navigation. Each running navigation exposes an AbortSignal to its guards: a newer navigation aborts the previous one, and an aborted navigation unwinds silently.
export function client ({ router }) {
const unregister = router.beforeEach(async ({ to, from, signal }) => {
if (to.path.startsWith('/admin') && !await isAuthenticated()) {
return '/login' // soft redirect to /login
}
if (to.path === '/private-external-origin') {
return 'https://external.example.com' // hard redirect, full document load
}
if (to.path === '/maintenance-hard-block') {
return false // cancel: stay on the current route
}
// undefined / true: proceed
})
// Call unregister() (e.g. on route-specific teardown) to remove the guard
}
Navigation Events #
The router dispatches two CustomEvents on window (bubbling and composed) with a detail payload of { from, to }, where both are full path strings (pathname + search + hash):
coralite:navigating— dispatched before the target page is fetched. It is cancelable: callingpreventDefault()on the event aborts the navigation.coralite:navigated— dispatched after a successful swap (post upgrade, post scroll). It is not cancelable; use it for post-navigation side effects such as analytics and third-party widget sync.
export function client ({ signal }) {
window.addEventListener('coralite:navigating', (event) => {
// Cancelable: abort the navigation
if (hasUnsavedChanges()) {
event.preventDefault()
}
}, { signal })
window.addEventListener('coralite:navigated', (event) => {
const { from, to } = event.detail
console.log(`Routed ${from} → ${to}`)
}, { signal })
}
Link Interception #
While the router is active, left-clicks on same-origin <a href> links are converted into client-side navigations. Interception is skipped — leaving the browser's native behavior intact — in the following cases:
outletOnly: trueis configured and the live document has no outlet.- The click is not the primary button (
button !== 0). - Any modifier key is held (
meta/ctrl/shift/alt). - The event has already been
preventDefaulted by other code. - The link has a
targetother than_self(e.g._blank). - The link carries a
downloadattribute. - The link carries the
data-router-ignoreattribute — the per-link opt-out that forces a native full document load. - The link is cross-origin.
<a href="/downloads/report.pdf" download="">Report PDF</a>
<a href="/legacy/checkout" target="_blank">Legacy Checkout</a>
<a href="/reports/export" data-router-ignore="">Full-Reload Export</a>
Behavior Contract #
- Back/forward (
popstate). Browser history navigation re-fetches and re-renders the current route's static HTML vianavigate(currentPath, { replace: true }), so every back/forward step renders the route's canonical pre-built document. autoWrap: false. The server-sideonBeforePageRenderhook skips the automatic outlet wrapper. Page rendering and token replacement are unchanged; the page simply has no outlet unless authored explicitly. Client navigation to such a page still fails because the fetched document has no outlet.outletOnly: true. The click guard only affects native link interception. It is evaluated before link detection, so on an outlet-less page the click proceeds as a normal full-page navigation. It does not changerouter.navigate()orpopstatehandling.- Programmatic navigation from outlet-less pages.
router.navigate()pushes or replaces the history entry before fetching the target. If the live document has no outlet, the promise rejects after the URL has already changed. Prefer native links on outlet-less pages, or combineautoWrap: falsewithoutletOnly: trueso clicks stay native. - Head reconciliation. After each navigation the live document's
<title>and<meta name|property>tags are reconciled against the fetched page: added, updated, and removed tags are the union of both sets keyed byname/property. - Hydration-store re-store. The fetched page's hydration payload (
<script data-coralite-hydration>) is parsed and installed into the shared hydration store (runtime.state.replaceHydration()) before the swap, so component state for the new route is current before upgrade. - Component re-hydration. After the swap, the new outlet content is upgraded (
runtime.dom.upgrade(outlet)), so every SPA navigation yields fully hydrated component instances; out-of-outlet elements are never re-instantiated. - Scroll position. The window scrolls to the top after each navigation unless the target path contains a
#hash fragment (hash-targeted navigation preserves scroll). - Fetch contract. SPA navigation requests carry the header
X-Coralite-Router: trueso static hosts, service workers, or edge functions can distinguish client route fetches from initial page loads. A non-OK response, or a fetched document without an outlet, fails the navigation with an error. - View Transitions interplay. When the project sets
viewTransitions: trueincoralite.config.js, the browser supportsdocument.startViewTransition, and the user has not enabledprefers-reduced-motion: reduce, the outlet swap executes inside a native View Transition. Otherwise the swap is a direct DOM replacement. (Distinct from the native MPA crossfades thatviewTransitionsalso drives between full page loads.)