A client-side routing system for single-page applications. The router:
- Matches URLs to route configurations
- Extracts typed parameters from URL segments
- Manages browser history (back/forward navigation)
- Dispatches
NavigateRouteEventto render components - Supports multiple rendering targets (main content, sidebars, modals)
- Handles layout switching between different HTML shells
The router itself only handles matching and navigation. Rendering is handled by <r-route-target> components that listen for navigation events.
import { Route, defineRoutes, startRouting } from '@relax.js/core/routing';
const routes: Route[] = [
{ name: 'home', path: '/', componentTagName: 'home-page' },
{ name: 'users', path: '/users', componentTagName: 'user-list' },
{ name: 'user', path: '/users/:id', componentTagName: 'user-profile' },
];
defineRoutes(routes);
startRouting();<body>
<nav>
<r-link name="home">Home</r-link>
<r-link name="users">Users</r-link>
</nav>
<main>
<r-route-target></r-route-target>
</main>
</body>Each route specifies how a URL maps to a component:
interface Route {
name?: string; // Identifier for programmatic navigation
path: string; // URL pattern with parameters
componentTagName?: string; // Custom element tag name
component?: WebComponentConstructor; // Or class reference
load?: () => Promise<unknown>; // Imports componentTagName on first visit
target?: string; // Named r-route-target (default: unnamed)
layout?: string; // HTML file for different shells
guards?: RouteGuard[]; // Access control
}component takes the class, so the page's module is imported at startup and the import can never
be dropped by the bundler. componentTagName takes the tag name; the module defining it must
still be imported before defineRoutes(), unless the route has load.
A page that should stay out of the startup bundle names its tag and how to import it:
import type { ProfilePage } from './pages/ProfilePage';
const routes: Route[] = [
{ name: 'home', path: '/', component: HomePage },
{
name: 'profile',
path: '/profile/:id',
componentTagName: 'profile-page',
load: () => import('./pages/ProfilePage'),
},
];The first navigation to profile awaits load() and then creates <profile-page>. Later
navigations find the tag defined and skip it. Vite and esbuild put each import() in its own
chunk. The page's own imports, such as the services it injects, run before the page is defined,
so they are ready when it constructs.
import type keeps the class available for navigate<ProfilePage>() and similar without
pulling the module into the startup bundle.
If load() rejects, or finishes without defining the tag, the navigation is reported through
onError with the route name and tag, and nothing is rendered.
| Syntax | Type | Example | Matches |
|---|---|---|---|
text |
Static segment | /users |
Exactly "users" |
:name |
String parameter | /users/:id |
/users/john → { id: 'john' } |
;name |
Number parameter | /orders/;orderId |
/orders/123 → { orderId: 123 } |
Number parameters (;) validate that the segment contains only digits and convert to number type. String parameters (:) accept any value and remain as string.
const routes: Route[] = [
{ name: 'home', path: '/' },
{ name: 'user', path: '/users/:userName' }, // String: userName
{ name: 'order', path: '/orders/;orderId' }, // Number: orderId
{ name: 'category', path: '/shop/:category/items' }, // Mixed static + param
];The simplest way to navigate is with <r-link>:
<r-link name="home">Home</r-link>
<r-link name="user" param-user-name="john">View Profile</r-link>
<r-link name="order" param-order-id="123">Order Details</r-link>Multi-word parameter names can be written in either kebab-case or camelCase. Both target the same route parameter (e.g. :userName):
<r-link name="user" param-user-name="john">Kebab-case</r-link>
<r-link name="user" param-userName="john">camelCase</r-link>Use navigate() for navigation from code:
import { navigate } from '@relax.js/core/routing';
// By route name with parameters
navigate('user', { params: { userName: 'john' } });
navigate('order', { params: { orderId: 123 } });
// By URL directly
navigate('/users/john');
navigate('/orders/123');
// To a specific target
navigate('preview', { params: { id: '42' }, target: 'modal' });Routes can render in different <r-route-target> elements:
<div class="layout">
<aside>
<r-route-target name="sidebar"></r-route-target>
</aside>
<main>
<r-route-target></r-route-target>
</main>
</div>const routes: Route[] = [
{ name: 'home', path: '/', componentTagName: 'home-page' },
{ name: 'menu', path: '/menu', target: 'sidebar', componentTagName: 'nav-menu' },
];Routes without a target property render in the default (unnamed) target.
The router integrates with the browser's History API:
navigate()callshistory.pushState()to add entries- Back/forward buttons replay previous routes (components receive
loadRouteagain with the original route data) startRouting()reads the current URL and navigates on page load
URLs display the route path (e.g., /users/john), not the HTML file.
Each <r-route-target> keeps its own navigation history. The history records every route that was rendered into that target. Three things can walk a target's history:
- The browser back/forward buttons (replays automatically via
popstate). - The programmatic API:
navigateBack(),navigateForward(),canGoBack(),canGoForward(). - The
<r-link direction="back">and<r-link direction="forward">components for declarative back/forward links.
Histories are independent: navigating back in a modal target does not affect the main content target.
import { navigateBack, navigateForward, canGoBack } from '@relax.js/core/routing';
// Step the default target back
if (canGoBack()) navigateBack();
// Step a named target back
navigateBack('modal');Replayed navigations carry isReplay: true and re-use the original entryId, so listeners and analytics can distinguish them from fresh navigations:
document.addEventListener('rlx.navigateRoute', (e) => {
if (e.isReplay) {
console.log('back/forward replay, original entry', e.entryId);
}
});Different parts of your application may need different HTML shells (navigation, sidebar presence, etc.). See Layouts for details.
const routes: Route[] = [
{ name: 'login', path: '/login', componentTagName: 'login-page', layout: 'public' },
{ name: 'dashboard', path: '/dashboard', componentTagName: 'dashboard-page' }, // default layout
];When navigating between layouts, the router redirects to the appropriate HTML file and resumes navigation.
Guards control access to routes:
import { RouteGuard, GuardResult, RouteMatchResult } from '@relax.js/core/routing';
class AuthGuard implements RouteGuard {
check(route: RouteMatchResult): GuardResult {
if (isAuthenticated()) {
return GuardResult.Continue; // Check other guards
}
navigate('login');
return GuardResult.Stop; // Prevent navigation
}
}
const routes: Route[] = [
{ name: 'dashboard', path: '/dashboard', componentTagName: 'dashboard-page', guards: [new AuthGuard()] },
];| Result | Behavior |
|---|---|
Allow |
Proceed immediately, skip remaining guards |
Continue |
Check next guard (or proceed if none left) |
Stop |
Cancel navigation silently |
Deny |
Cancel navigation and throw RouteGuardError |
Navigation dispatches NavigateRouteEvent on document:
import { NavigateRouteEvent } from '@relax.js/core/routing';
document.addEventListener('rlx.navigateRoute', (e: NavigateRouteEvent) => {
console.log('Route:', e.route.name);
console.log('Params:', e.routeData);
console.log('Target:', e.routeTarget ?? 'default');
console.log('Fragment:', e.fragment);
});This event is typed in HTMLElementEventMap for full TypeScript support.
For advanced use cases, match routes without navigating:
import { matchRoute, findRouteByUrl, findRouteByName } from '@relax.js/core/routing';
// Match by URL
const result = matchRoute(routes, '/users/john');
// { route: {...}, params: { userName: 'john' }, urlSegments: ['users', 'john'] }
// Match by name
const result = matchRoute(routes, 'user', { userName: 'john' });
// Direct functions
findRouteByUrl(routes, '/users/john');
findRouteByName(routes, 'user', { userName: 'john' });Components rendered by <r-route-target> can receive route parameters in two ways.
Implement loadRoute() to run async setup before the component is added to the DOM. The component is not visible until loadRoute() completes.
import { LoadRoute, RouteData } from '@relax.js/core/routing';
class OrderDetail extends HTMLElement implements LoadRoute<{ orderId: number }> {
private order: Order;
async loadRoute(data: { orderId: number }) {
this.order = await fetchOrder(data.orderId);
}
connectedCallback() {
this.render(this.order);
}
}If the route carries no parameters at all, there is nothing to hand loadRoute(). The router warns about it and passes { r_error: '...' } instead, so a component that reads the value can tell it came from the router rather than from your route. The r_ prefix marks it as ours. Either give the route parameters, or drop loadRoute() and use connectedCallback().
Implement Routable to receive parameters as a typed property. The property is optional since it's set by the router after construction.
import { Routable } from '@relax.js/core/routing';
class UserProfile extends HTMLElement implements Routable<{ userName: string }> {
routeData?: { userName: string };
}For convention-based usage without the interface, declare routeData directly on your component. The router always assigns it regardless.
Both can be combined. loadRoute() runs first, then routeData is assigned.
The fragment is the part of a URL after #. Browsers never send it to the server, so it is the one place you can put a value that will not show up in server, proxy or CDN access logs. Activation and password reset links sometimes use this for their token.
A fragment has no name, so it is not merged into route parameters the way a query string is. Read it from the navigation event instead:
import { NavigateRouteEvent } from '@relax.js/core/routing';
document.addEventListener('rlx.navigateRoute', (e: NavigateRouteEvent) => {
if (e.route.name === 'reclaim') {
const resetToken = e.fragment;
}
});fragment is undefined when the URL had no fragment. It holds the value without the leading #, and it survives a layout switch.
Two things to be aware of:
- The router removes the fragment from the address bar once routing settles, because
pushStaterewrites the URL from the route's own segments. A page reload will therefore not see it again. For a single use token that is usually what you want, but you need a "link expired, request a new one" state for the reload case. - A fragment stays out of server logs, but it is still part of the URL in the browser's history, and most analytics and error reporting scripts read
location.hrefrather thanlocation.pathname. Check what your instrumentation captures before putting a secret there.
Use a query string instead when the value is not sensitive. Query parameters are merged straight into route parameters, and they stay in the address bar, so location.search still holds them and a reload sees them again:
// /users/john?tab=orders
class UserProfile extends HTMLElement {
routeData?: { id: string; tab: string };
}import { RouteError, RouteGuardError } from '@relax.js/core/routing';
try {
navigate('unknown-route');
} catch (e) {
if (e instanceof RouteGuardError) {
console.log('Access denied');
} else if (e instanceof RouteError) {
console.log('Route not found');
}
}When no route matches, the error message lists all available routes for debugging.
Routing errors are reported through the global error handler. The error context contains:
| Field | Description |
|---|---|
route |
Route name |
componentTagName |
Custom element tag name |
component |
Component class name (if using class reference) |
routeData |
Parameters extracted from the URL |
| Function | Description |
|---|---|
defineRoutes(routes) |
Register routes at startup |
startRouting() |
Initialize router and navigate to current URL |
navigate(nameOrUrl, options?) |
Navigate to a route |
navigateBack(target?) |
Replay the previous navigation in a target's history |
navigateForward(target?) |
Replay the next navigation in a target's history |
canGoBack(target?) |
true when a target has a previous entry |
canGoForward(target?) |
true when a target has a forward entry |
matchRoute(routes, nameOrUrl, params?) |
Match without navigating |
findRouteByUrl(routes, path) |
Match by URL pattern |
findRouteByName(routes, name, params) |
Match by route name |
type RouteParamType = string | number;
type RouteData = Record<string, RouteParamType>;
interface NavigateOptions {
params?: Record<string, string | number>;
target?: string;
routes?: Route[]; // Override registered routes
fragment?: string; // URL fragment, without '#'
}
interface RouteMatchResult {
route: Route;
params: RouteData;
urlSegments: string[];
fragment?: string;
}
enum GuardResult {
Allow, // Proceed, skip remaining guards
Deny, // Throw RouteGuardError
Continue, // Check next guard
Stop // Cancel silently
}<r-route-target>- Renders routed components<r-link>- Declarative navigation links- Layouts - Multiple HTML shells