Eunomia v1.1.0
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

SPA navigation

Eunomia is framework-agnostic, so a link-like component can't import next/link, Angular's RouterLink, or any other router directly. It renders a real <a href> instead, which works everywhere with no setup at all, and gives your app one hook to turn those links into client-side navigations: the eunnavigate event. A small router bridge, written once and mounted once near the root of the app, is then enough to route every Eunomia link through your router.

How eunnavigate works

Right before following a link, the component dispatches a NavigationEvent named eunnavigate, carrying the destination on event.href. It's raised by Link, Button and Fab with href set, and Card (including its image and sliding variants) with href set. Breadcrumb and the Toolbar logo need no bridge at all: they're your router's own links.

The event is:

  • cancelable: calling event.preventDefault() tells the component your router took over, and the component then cancels the native click so the browser never performs a full page load on top of it;
  • bubbling and composed: it crosses every shadow root on its way up, so a single listener on document catches it from any component, however deeply nested;
  • optional: when nothing calls preventDefault(), the browser follows the link natively. An app with no bridge, or a link the bridge chooses to leave alone, keeps working exactly like a plain <a>.

The event is never dispatched for a modified click (Ctrl/Cmd/Shift/Alt, middle click), for a target other than _self or unset, or for a download link. Those always need the browser's own handling (new tab, new window, file download), so a bridge never has to filter them out itself.

Deciding what to intercept

A bridge must never intercept every link: an external site, a mailto: or tel: URI, or a page served by another app on the same domain has to keep navigating natively. Rather than repeating that check in every framework, put it in one plain function that every bridge below reuses:

// eunomia-navigation.ts
import type { NavigationEvent } from "@eunomia/elements";

/**
 * Returns the in-app path an `eunnavigate` event points to, or `null`
 * when the browser should handle the navigation natively.
 *
 * @param event the `eunnavigate` event.
 * @param basePath the path prefix the app is served under, if any
 * (for example `"/docs"`), stripped from the returned path.
 */
export function getInternalPath(
  event: NavigationEvent,
  basePath = "",
): string | null {
  if (event.defaultPrevented) {
    return null;
  }

  let url: URL;
  try {
    url = new URL(event.href, document.baseURI);
  } catch {
    return null;
  }

  // Another origin, or a mailto:/tel:/... URI (whose origin is "null").
  if (url.origin !== window.location.origin) {
    return null;
  }

  // A same-page anchor: let the browser scroll to it natively.
  const isSamePage =
    url.pathname === window.location.pathname &&
    url.search === window.location.search;
  if (isSamePage && url.hash) {
    return null;
  }

  let path = url.pathname;
  if (basePath) {
    if (path !== basePath && !path.startsWith(`${basePath}/`)) {
      // Same domain, but outside this app.
      return null;
    }
    path = path.slice(basePath.length) || "/";
  }

  return `${path}${url.search}${url.hash}`;
}

Resolving href into a full URL first is what makes this work for every way a link can be written: an absolute path (/about), a relative one (about, ../about), a full URL pointing back at the same site (https://example.com/about), or a protocol-relative one (//other.com/about, which a naive href.startsWith("/") check would wrongly treat as internal).

In a plain JavaScript project, the same file works as eunomia-navigation.js once the import type line and the type annotations are removed.

Importing NavigationEvent with import type is erased at build time, so this file never registers any component itself and is safe to import from code that also runs during a server render.

Mounting the bridge

Every bridge follows the same three steps: listen for eunnavigate on document, ask getInternalPath whether the link belongs to the app, and if it does, cancel the event and hand the path to the router. Mount it once, at the root of the app, and every Eunomia link on every page is covered, including ones rendered later.

The bridges below adapt to the framework picked in the toolbar above: pick one to narrow this down to just that framework.

Next.js (App Router)

A client component rendering nothing, mounted once in the root layout:

// app/eunomia-router-bridge.tsx
"use client";

import { useRouter } from "next/navigation";
import { useEffect } from "react";
import type { NavigationEvent } from "@eunomia/elements";
import { getInternalPath } from "./eunomia-navigation";

export function EunomiaRouterBridge() {
  const router = useRouter();

  useEffect(() => {
    const handleNavigate = (event: NavigationEvent) => {
      const path = getInternalPath(event);
      if (path === null) {
        return;
      }
      event.preventDefault();
      router.push(path);
    };

    document.addEventListener("eunnavigate", handleNavigate);
    return () => document.removeEventListener("eunnavigate", handleNavigate);
  }, [router]);

  return null;
}
// app/layout.tsx
import { EunomiaRouterBridge } from "./eunomia-router-bridge";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <EunomiaRouterBridge />
        {children}
      </body>
    </html>
  );
}

With a basePath set in next.config.js, write your hrefs with that prefix (they're plain anchors, so the native fallback needs it) and pass it as getInternalPath(event, "/docs"): router.push adds it back on its own.

React Router

The same idea as a component rendering nothing. Mount it anywhere inside the router (for example at the top of the root route's layout), since useNavigate only works there:

import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import type { NavigationEvent } from "@eunomia/elements";
import { getInternalPath } from "./eunomia-navigation";

export function EunomiaRouterBridge() {
  const navigate = useNavigate();

  useEffect(() => {
    const handleNavigate = (event: NavigationEvent) => {
      const path = getInternalPath(event);
      if (path === null) {
        return;
      }
      event.preventDefault();
      navigate(path);
    };

    document.addEventListener("eunnavigate", handleNavigate);
    return () => document.removeEventListener("eunnavigate", handleNavigate);
  }, [navigate]);

  return null;
}

With a basename set on the router, pass it as the second argument of getInternalPath: navigate resolves the path against it on its own.

Vue Router

The router is a plain object, so the bridge doesn't need a component at all. Install it once, right after creating the router:

// eunomia-router-bridge.ts
import type { Router } from "vue-router";
import type { NavigationEvent } from "@eunomia/elements";
import { getInternalPath } from "./eunomia-navigation";

export function installEunomiaRouterBridge(router: Router): () => void {
  const handleNavigate = (event: NavigationEvent) => {
    const path = getInternalPath(event, router.options.history.base);
    if (path === null) {
      return;
    }
    event.preventDefault();
    router.push(path);
  };

  document.addEventListener("eunnavigate", handleNavigate);
  return () => document.removeEventListener("eunnavigate", handleNavigate);
}
// main.ts
const router = createRouter({ history: createWebHistory(), routes });
installEunomiaRouterBridge(router);
createApp(App).use(router).mount("#app");

Passing router.options.history.base keeps the bridge correct when the app is served under a sub-path, with nothing to keep in sync by hand.

On Nuxt, the same listener goes in a client-only plugin, which never runs during the server render:

// plugins/eunomia-router-bridge.client.ts
import type { NavigationEvent } from "@eunomia/elements";
import { getInternalPath } from "~/utils/eunomia-navigation";

export default defineNuxtPlugin(() => {
  const basePath = useRuntimeConfig().app.baseURL.replace(/\/$/, "");

  document.addEventListener("eunnavigate", (event: NavigationEvent) => {
    const path = getInternalPath(event, basePath);
    if (path === null) {
      return;
    }
    event.preventDefault();
    navigateTo(path);
  });
});

Angular

A root service registering the listener once, skipped during a server render:

// eunomia-router-bridge.service.ts
import { DOCUMENT, PlatformLocation, isPlatformBrowser } from "@angular/common";
import { Injectable, PLATFORM_ID, inject } from "@angular/core";
import { Router } from "@angular/router";
import type { NavigationEvent } from "@eunomia/elements";
import { getInternalPath } from "./eunomia-navigation";

@Injectable({ providedIn: "root" })
export class EunomiaRouterBridge {
  private readonly router = inject(Router);
  private readonly document = inject(DOCUMENT);
  private readonly basePath = inject(PlatformLocation)
    .getBaseHrefFromDOM()
    .replace(/\/$/, "");

  constructor() {
    if (!isPlatformBrowser(inject(PLATFORM_ID))) {
      return;
    }
    this.document.addEventListener("eunnavigate", (event: NavigationEvent) => {
      const path = getInternalPath(event, this.basePath);
      if (path === null) {
        return;
      }
      event.preventDefault();
      this.router.navigateByUrl(path);
    });
  }
}

A service only runs once something injects it. On Angular 19 and later, do it at startup in app.config.ts:

import { inject, provideAppInitializer } from "@angular/core";

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideAppInitializer(() => {
      inject(EunomiaRouterBridge);
    }),
  ],
};

On earlier versions, inject it in the root AppComponent's constructor instead.

SvelteKit

Register the listener in the root layout's onMount, which only runs in the browser:




Unlike the other routers here, goto doesn't add paths.base back on its own, so leave basePath out and keep the full path.

Any other router

A bridge only needs your router's own "navigate to this path" call. Plug it in wherever the app starts:

import { getInternalPath } from "./eunomia-navigation.js";

document.addEventListener("eunnavigate", (event) => {
  const path = getInternalPath(event);
  if (path === null) {
    return;
  }
  event.preventDefault();
  router.navigate(path); // your router's own navigation call
});

An app with no client-side router at all needs no bridge: every link simply navigates natively.

Checking the bridge works

Open the browser's network panel and click an internal Eunomia link. With the bridge mounted, no new document request appears and the page state (a scroll position in a sidebar, an open panel) survives the navigation. Without it, or for a link the bridge leaves alone, the network panel shows a full document load, exactly as for a plain <a>.

A bridge listening on document sees every eunnavigate on the page. To handle one link differently (for example to confirm before leaving a form with unsaved changes), listen on that link or one of its ancestors and call preventDefault() there: getInternalPath returns null for an event that's already been prevented, so the global bridge steps aside.

See also