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:

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:

javascript
Code copied!
  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
      })
    ]
  })

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:

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):

HTML
Code copied!
<!-- 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:

HTML
Code copied!
<!-- 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:

HTML
Code copied!
<!-- 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.

javascript
Code copied!
  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):

javascript
Code copied!
  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.

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:

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:

javascript
Code copied!
  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
    })
  }
javascript
Code copied!
  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)
  }

The router also speaks plain DOM events on window, which keeps integrations (analytics, third-party widgets, feature flags) loose-coupled from the router API.

javascript
Code copied!
  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.

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:

HTML
Code copied!
<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:

javascript
Code copied!
  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
    }
  })
HTML
Code copied!
<!-- 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>

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:

javascript
Code copied!
  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 #

Start Building with Coralite!

Use the scaffolding script to get jump started into your next project with Coralite

Copied commandline!