The Router is a lightweight, client-side routing library designed for Web Components applications. It enables single-page application (SPA) navigation with support for dynamic route matching, path parameters, and route guards.
The router is built on top of the browser's History API and handles navigation through data-link attributes on clickable elements.
A route defines a path pattern and the component to render when that path is accessed. Each route consists of:
- path - The URL path pattern (e.g.,
/home,/users/:id) - component - The Web Component tag name to render
- load - An async function to load the component module
- guard - (Optional) A function to protect the route with conditional access
The router uses path pattern matching to determine which component to render:
- Static routes - Exact path matches (e.g.,
/homematches only/home) - Dynamic routes - Support path parameters with
:paramNamesyntax - Parameters are extracted and made available to the routed component
There are two primary ways to trigger navigation:
- Browser Navigation - Using back/forward buttons (handled via
popstateevents) - Link Clicks - Clicking elements with
data-linkattribute
import { initUI } from '@diniz/webcomponents';
const routes = [
{
path: '/',
component: 'page-home',
load: () => import('./pages/home')
},
{
path: '/about',
component: 'page-about',
load: () => import('./pages/about')
}
];
initUI({
theme: 'shadcn',
routes,
outlet: '#app'
});Helper reference:
docs/VITE_HELPERS.md
createRouter(routes, appSelectorOrOptions)
routes— Array ofRouteobjectsappSelectorOrOptions— Either a CSS selector string (default:'#app') or aRouterOptionsobject
type RouterOptions = {
/** CSS selector for the router outlet element. Default: '#app' */
outlet?: string;
/**
* Base path prefix this router is responsible for.
* Routes are matched relative to this base, enabling nested routers.
* Example: basePath '/dashboard' makes route '/overview' match '/dashboard/overview'.
* Default: '' (root router)
*/
basePath?: string;
};| Option | Type | Default | Description |
|---|---|---|---|
outlet |
string |
'#app' |
CSS selector for the element where components are rendered |
basePath |
string |
'' |
Path prefix this router owns. Used to create nested (child) routers |
type Route = {
path: string;
load: () => Promise<object>;
component: string;
guard?: () => boolean | Promise<boolean>;
};Use /* as a suffix to match a path and all its descendants. This is required to hand off nested paths to a child router.
{
path: '/dashboard/*',
component: 'page-dashboard',
load: () => import('./pages/dashboard')
}Matching Examples:
- ✅
/dashboardmatches - ✅
/dashboard/overviewmatches - ✅
/dashboard/reports/monthlymatches - ❌
/settingsdoes not match
Static routes match an exact path without parameters.
{
path: '/home',
component: 'page-home',
load: () => import('./pages/home')
}Matching Examples:
- ✅
/homematches - ❌
/home/does not match - ❌
/home/editdoes not match
Use :paramName syntax to define dynamic segments in routes.
{
path: '/recording/:id',
component: 'page-recording',
load: () => import('./pages/recording')
}Matching Examples:
- ✅
/recording/123matches with{ id: '123' } - ✅
/recording/abcmatches with{ id: 'abc' } - ❌
/recordingdoes not match - ❌
/recording/123/editdoes not match
{
path: '/users/:userId/posts/:postId',
component: 'page-user-post',
load: () => import('./pages/user-post')
}Matching Examples:
- ✅
/users/42/posts/123matches with{ userId: '42', postId: '123' } - ✅
/users/john/posts/hellomatches with{ userId: 'john', postId: 'hello' }
{
path: '/dashboard/:section/analytics',
component: 'page-analytics',
load: () => import('./pages/analytics')
}Matching Examples:
- ✅
/dashboard/sales/analyticsmatches with{ section: 'sales' } - ✅
/dashboard/users/analyticsmatches with{ section: 'users' }
You can create a second router inside a component — for example, inside a layout that has its own navigation area (tabs, sidebar sections). This is the typical pattern when using ui-layout.
- The outer router uses a wildcard route (
/section/*) to match the entry component. - The entry component creates an inner router with
basePathset to that prefix. - The inner router only processes paths that start with its
basePath; the outer router ignores them. - Each router renders into its own outlet element.
// main.ts
import { createRouter } from '@diniz/webcomponents';
createRouter([
{ path: '/', component: 'page-home', load: () => import('./pages/home') },
{ path: '/about', component: 'page-about', load: () => import('./pages/about') },
// Wildcard: hand off anything under /dashboard to the inner router
{ path: '/dashboard/*', component: 'page-dashboard', load: () => import('./pages/dashboard') },
]);// pages/dashboard.ts
import { LitComponent } from '@/core/lit-component';
import { customElement } from 'lit/decorators.js';
import { html } from 'lit';
import { createRouter } from '@diniz/webcomponents';
@customElement('page-dashboard')
export class PageDashboard extends LitComponent {
firstUpdated() {
// Create the inner router AFTER the component is in the DOM
createRouter([
{ path: '/', component: 'dash-overview', load: () => import('./dash/overview') },
{ path: '/reports', component: 'dash-reports', load: () => import('./dash/reports') },
{ path: '/users', component: 'dash-users', load: () => import('./dash/users') },
], {
outlet: '#dashboard-outlet', // outlet inside this component's shadow/light DOM
basePath: '/dashboard', // only handles paths starting with /dashboard
});
}
render() {
return html`
<ui-layout>
<ui-layout-sidebar>
<nav>
<a href="/dashboard" data-link>Overview</a>
<a href="/dashboard/reports" data-link>Reports</a>
<a href="/dashboard/users" data-link>Users</a>
</nav>
</ui-layout-sidebar>
<ui-layout-main>
<div id="dashboard-outlet"></div>
</ui-layout-main>
</ui-layout>
`;
}
}Click events on [data-link] elements are automatically scoped:
- A link to
/aboutis handled only by the outer router. - A link to
/dashboard/reportsis handled only by the inner router (whosebasePathis/dashboard).
This means both routers can coexist on the same page without interfering with each other.
A nested router created inside a component (long after DOMContentLoaded) will run immediately because createRouter checks document.readyState at call time:
- If the DOM is still loading → waits for
DOMContentLoaded. - If the DOM is already ready → runs the router function immediately.
Always create the inner router in connectedCallback, firstUpdated, or equivalent lifecycle hooks to ensure the outlet element exists before the router tries to render into it.
Guards work the same way inside a nested router:
createRouter([
{
path: '/admin',
component: 'dash-admin',
load: () => import('./dash/admin'),
guard: async () => {
const user = await getUser();
return user.role === 'admin';
}
}
], { outlet: '#dashboard-outlet', basePath: '/dashboard' });When the guard returns false, the user is redirected to the nested root (basePath + '/').
Guards protect routes with conditional access logic. A guard function receives no parameters and should return true to allow access or false to deny it.
{
path: '/admin',
component: 'page-admin',
load: () => import('./pages/admin'),
guard: () => isUserAdmin()
}Guards can be asynchronous:
{
path: '/settings',
component: 'page-settings',
load: () => import('./pages/settings'),
guard: async () => {
const user = await fetchUser();
return user.isAuthenticated;
}
}When a guard returns false:
- The component does not render
- The user is redirected to the home page (
/) - The browser history is updated
Add data-link attribute to clickable elements to enable client-side navigation:
<a href="/home" data-link>Home</a>
<button href="/about" data-link>About</button>Benefits:
- Prevents full page reload
- Updates URL without server request
- Maintains application state
The router automatically listens to browser back/forward button clicks through the popstate event.
While not directly exposed by the router, you can use the History API:
import { buildPath } from '@diniz/webcomponents';
const fullPath = buildPath('/recording/123');
history.pushState(null, '', fullPath);
router(); // Manually trigger routerExtract path parameters using either router.getParam(name) or getPathParams.
import { createRouter } from '@diniz/webcomponents';
const router = createRouter([
{
path: '/users/:userId/posts/:postId',
component: 'page-user-post',
load: () => import('./pages/user-post')
}
]);
// When current URL is /users/42/posts/123
router.getParam('userId'); // '42'
router.getParam('postId'); // '123'
router.getParam('unknown'); // nullExtract path parameters directly from two path strings:
import { getPathParams } from '@diniz/webcomponents';
export class PageRecording extends BaseComponent {
connectedCallback() {
const params = getPathParams('/dashboard/recording/:id', location.pathname);
const recordingId = params?.id;
}
}Extracts the route path, accounting for a base path configuration.
import { getRoutePath } from '@diniz/webcomponents';
// With BASE_URL = '/'
getRoutePath('/home'); // Returns '/home'
// With BASE_URL = '/app'
getRoutePath('/app/home'); // Returns '/home'Constructs a full path by prepending the base path.
import { buildPath } from '@diniz/webcomponents';
// With BASE_URL = '/'
buildPath('/home'); // Returns '/home'
// With BASE_URL = '/app'
buildPath('/home'); // Returns '/app/home'The router respects the BASE_URL from Vite's import.meta.env. This is useful for hosting applications at a subpath.
// vite.config.ts
export default {
base: '/app/'
};Routes continue to be defined relative to the root:
// Still use '/' for the root of the app
{
path: '/',
component: 'page-home',
load: () => import('./pages/home')
}
// But the browser URL becomes '/app/'import { createRouter } from '@diniz/webcomponents';
const routes = [
{
path: '/',
component: 'page-home',
load: () => import('./pages/home')
},
{
path: '/about',
component: 'page-about',
load: () => import('./pages/about')
},
{
path: '/recording/:id',
component: 'page-recording',
load: () => import('./pages/recording')
},
{
path: '/admin',
component: 'page-admin',
load: () => import('./pages/admin'),
guard: () => isUserAdmin()
},
{
path: '/users/:userId/posts/:postId',
component: 'page-user-post',
load: () => import('./pages/user-post')
}
];
createRouter(routes, '#app');// main.ts — outer router
import { createRouter } from '@diniz/webcomponents';
createRouter([
{ path: '/', component: 'page-home', load: () => import('./pages/home') },
{ path: '/about', component: 'page-about', load: () => import('./pages/about') },
{ path: '/dashboard/*', component: 'page-dashboard', load: () => import('./pages/dashboard') },
]);// pages/dashboard.ts — inner router inside ui-layout
import { LitComponent } from '@/core/lit-component';
import { customElement } from 'lit/decorators.js';
import { html } from 'lit';
import { createRouter } from '@diniz/webcomponents';
@customElement('page-dashboard')
export class PageDashboard extends LitComponent {
firstUpdated() {
createRouter([
{ path: '/', component: 'dash-overview', load: () => import('./dash/overview') },
{ path: '/reports', component: 'dash-reports', load: () => import('./dash/reports') },
], {
outlet: '#dash-outlet',
basePath: '/dashboard',
});
}
render() {
return html`
<ui-layout>
<ui-layout-sidebar>
<a href="/dashboard" data-link>Overview</a>
<a href="/dashboard/reports" data-link>Reports</a>
<a href="/about" data-link>About (outer)</a>
</ui-layout-sidebar>
<ui-layout-main>
<div id="dash-outlet"></div>
</ui-layout-main>
</ui-layout>
`;
}
}Web Components rendered by the router can access path parameters:
import { LitComponent } from '@/core/lit-component';
import { getPathParams } from '@diniz/webcomponents';
import { html } from 'lit';
export class PageRecording extends LitComponent {
private recordingId: string | null = null;
connectedCallback() {
super.connectedCallback();
const params = getPathParams('/recording/:id', location.pathname);
this.recordingId = params?.id ?? null;
this.requestUpdate();
}
render() {
return html`<h1>Recording ${this.recordingId}</h1>`;
}
}
customElements.define('page-recording', PageRecording);Routes are evaluated in the order they are defined in the routes array. The first matching route is selected.
const routes = [
{ path: '/users/:id', ... }, // Matches first
{ path: '/users/admin', ... } // Never matches because above is checked first
];Best Practice: Define more specific routes before generic ones.
The router focuses on path parameters. Query parameters (?key=value) are not processed by the router automatically.
For query parameters, use the standard browser APIs:
const params = new URLSearchParams(location.search);
const token = params.get('token'); // Get ?token=abcIf no route matches the current path:
- The user is redirected to the home route (
/) - No component is rendered
If the load() function throws an error, the navigation will not complete. Consider implementing error boundaries in your components.
Creates and initializes the router.
Parameters:
routes: Route[]— Array of route definitionsappSelectorOrOptions?: string | RouterOptions— Outlet selector string or aRouterOptionsobject (default:'#app')
Returns: RouterInstance — A callable router function plus helpers (router(), router.getParam(name)).
type RouterInstance = {
(): Promise<void>;
getParam: (name: string) => string | null;
};type RouterOptions = {
outlet?: string; // Default: '#app'
basePath?: string; // Default: '' (root)
};type Route = {
path: string;
load: () => Promise<object>;
component: string;
guard?: () => boolean | Promise<boolean>;
};Extracts parameters from a path using a route pattern.
Parameters:
routePath: string- Route pattern (e.g.,/users/:id)path: string- Actual path (e.g.,/users/42)
Returns: Record<string, string> | null - Parameters object or null if no match
Extracts the route path from a full browser path.
Parameters:
fullPath: string- Full path fromlocation.pathname
Returns: string - Route path
Constructs a full browser path from a route path.
Parameters:
routePath: string- Route path (e.g.,/home)
Returns: string - Full browser path
- Routes are matched sequentially until a match is found
- Route guards are awaited, so use them carefully
- Components are loaded dynamically to keep bundle size small
- The router clears the previous component DOM when rendering a new one
The router uses the following browser APIs:
- History API - Path navigation
- Web Components - Component rendering
- Dynamic Imports - Lazy loading
Supported in all modern browsers (Chrome, Firefox, Safari, Edge).
- Check path segment count - routes must have the same number of segments
- Verify parameter syntax - use
:paramNameformat - Check route order - more specific routes must come before generic ones
- Guards are only checked after the route matches
- Ensure the route pattern matches your current path
- Check that the guard function is provided in the route definition
- Verify elements have
data-linkattribute - Check that the outlet selector exists in the DOM
- Ensure route components are properly registered as custom elements
- Confirm the component is registered with
customElements.define() - Verify the
load()function imports and exports the component - Check browser console for import errors
- Ensure the outer router has a wildcard route (
/section/*) for the section, not an exact path - Verify
basePathmatches the prefix used in the wildcard route (/dashboardfor/dashboard/*) - Create the inner router inside
firstUpdatedorconnectedCallback, not in the constructor — the outlet element must exist in the DOM first - Double-check the
outletselector matches an element that's actually rendered by the component
- A nested router with a
basePathwill only intercept[data-link]clicks whosehrefstarts with that base path - If a link inside a nested component points to a root-level path (e.g.,
/about), the inner router will ignore it and the outer router will handle it normally - If you see duplicate navigation, check that the
basePathoption is set on the inner router
- When a nested router finds no matching route, it redirects to
basePath + '/'(e.g.,/dashboard/) - Ensure your routes array includes a
'/'entry for the default view within the section