What's new in 1.14.0
Router gained NavigationResult, pushResult / replaceResult, beforeResolve, resolveRoute, dynamic addRoute / removeRoute / hasRoute, isReady, lastNavigation, and useNavigation() in 1.14.0. See the 1.14.0 release notes.
The router module provides SPA-style client-side routing built on the History API. It integrates seamlessly with bQuery's reactive system.
Internally, the router is now split into focused submodules (matching, navigation, state, links, utilities), and the public API now also includes the isNavigating reactive signal.
import { createRouter, navigate, currentRoute, isNavigating } from '@bquery/bquery/router';
import { effect } from '@bquery/bquery/reactive';Basic Setup
const router = createRouter({
routes: [
{ path: '/', component: () => import('./pages/Home') },
{ path: '/about', component: () => import('./pages/About') },
{ path: '/user/:id', component: () => import('./pages/User') },
{ path: '*', component: () => import('./pages/NotFound') },
],
});
// React to route changes
effect(() => {
const route = currentRoute.value;
console.log('Current path:', route.path);
console.log('Params:', route.params);
});Navigation
import { navigate, back, forward } from '@bquery/bquery/router';
// Push to history
await navigate('/dashboard');
// Replace current entry
await navigate('/login', { replace: true });
// Browser history
back();
forward();When you need a structured navigation outcome instead of thrown errors, use router.pushResult() / router.replaceResult():
const result = await router.pushResult('/dashboard');
if (result.status === 'completed') {
console.log('Current route:', result.to?.path);
}
if (result.status === 'canceled') {
console.log('Navigation was blocked by a guard');
}Route Params
Dynamic segments are defined with :paramName:
const router = createRouter({
routes: [
{ path: '/user/:id', component: () => import('./User') },
{ path: '/post/:slug/comment/:commentId', component: () => import('./Comment') },
],
});
// Navigating to /user/42
console.log(currentRoute.value.params); // { id: '42' }Regex constraints let you validate params directly in the route pattern:
const router = createRouter({
routes: [
{ path: '/user/:id(\\d+)', component: () => import('./User') },
{ path: '/docs/:slug([a-z0-9-]+)', component: () => import('./DocPage') },
],
});Query Params
Query strings are automatically parsed:
// URL: /search?q=hello&page=2
console.log(currentRoute.value.query); // { q: 'hello', page: '2' }
// Repeated keys become arrays
// URL: /search?tag=js&tag=ts
console.log(currentRoute.value.query); // { tag: ['js', 'ts'] }Navigation Guards
beforeEach
Run logic before every navigation. Return false to cancel:
router.beforeEach((to, from) => {
if (to.path === '/admin' && !isAuthenticated()) {
navigate('/login');
return false;
}
});afterEach
Run logic after successful navigation:
router.afterEach((to, from) => {
analytics.track('pageview', { path: to.path });
});beforeResolve
Run logic after beforeEach and route-level beforeEnter guards, but before the router commits the navigation to history:
router.beforeResolve(async (to) => {
if (to.path.startsWith('/billing')) {
const allowed = await canOpenBilling();
return allowed || false;
}
});Removing Guards
Both methods return a cleanup function:
const removeGuard = router.beforeEach((to, from) => {
// ...
});
// Later
removeGuard();beforeEnter
Individual routes can enforce route-local guards before global beforeEach logic finishes navigation.
const router = createRouter({
routes: [
{
path: '/admin',
beforeEnter: () => isAuthenticated() || false,
component: () => import('./Admin'),
},
],
});Redirect routes
Use redirectTo when a route exists only to forward users elsewhere.
const router = createRouter({
routes: [
{ path: '/docs', redirectTo: '/guide/getting-started' },
{ path: '/guide/getting-started', component: () => import('./DocsHome') },
],
});Named Routes
Define route names for easier programmatic navigation:
const router = createRouter({
routes: [
{ path: '/', name: 'home', component: () => import('./Home') },
{ path: '/user/:id', name: 'user', component: () => import('./User') },
],
});
// Resolve by name
import { resolve } from '@bquery/bquery/router';
const path = resolve('user', { id: '42' });
// Returns '/user/42'Use router.resolveRoute() when you need the router instance to resolve either a raw path or a named route descriptor into { path, href, matched } without performing navigation:
const info = router.resolveRoute({
name: 'user',
params: { id: '42' },
query: { tab: 'profile' },
hash: 'activity',
});
console.log(info.path); // '/user/42?tab=profile#activity'
console.log(info.href); // Includes the router base/hash modeNavigation State
Use isNavigating to reactively track in-flight navigation, including async guards and redirect resolution.
import { isNavigating } from '@bquery/bquery/router';
import { effect } from '@bquery/bquery/reactive';
effect(() => {
document.body.toggleAttribute('data-route-loading', isNavigating.value);
});This is useful for global loading indicators, disabling route-changing controls, or preventing duplicate navigation triggers while guards are still resolving.
The router also exposes the last completed/canceled/redirected/error outcome:
import { useNavigation } from '@bquery/bquery/router';
const { isNavigating, lastNavigation, status, error } = useNavigation();
effect(() => {
console.log(isNavigating.value, status.value, lastNavigation.value, error.value);
});Active Link Detection
import { isActive, isActiveSignal } from '@bquery/bquery/router';
// Immediate check
if (isActive('/dashboard')) {
navItem.classList.add('active');
}
// Reactive check
const dashboardActive = isActiveSignal('/dashboard');
effect(() => {
navItem.classList.toggle('active', dashboardActive.value);
});
// Exact matching
isActive('/dashboard', true); // Only matches exactly '/dashboard'useRoute() composable
useRoute() exposes focused readonly signals for the current route, path, params, query, hash, and matched definition.
import { useRoute } from '@bquery/bquery/router';
const { path, params, query, hash, matched } = useRoute();
effect(() => {
console.log(path.value, params.value, query.value, hash.value, matched.value);
});Initial readiness
router.isReady() resolves after the router finishes its initial synchronization. This is useful for hydration, integration tests, and code that needs the initial route match before rendering:
await router.isReady();
console.log(router.currentRoute.value.path);Link Helpers
Manual Link Handler
import { link } from '@bquery/bquery/router';
import { $ } from '@bquery/bquery/core';
$('#nav-home').on('click', link('/'));
$('#nav-about').on('click', link('/about'));Automatic Link Interception
Intercept all internal links in a container:
import { interceptLinks } from '@bquery/bquery/router';
// Intercept all links in document
const cleanup = interceptLinks(document.body);
// Links with target, download, or external URLs are ignoredDeclarative <bq-link>
Register the custom element once, then use it directly in templates or static HTML.
import { registerBqLink } from '@bquery/bquery/router';
registerBqLink();<bq-link to="/" exact>Home</bq-link>
<bq-link to="/docs" active-class="selected current">Docs</bq-link>
<bq-link to="/settings" replace>Settings</bq-link><bq-link> applies aria-current="page" when active and respects modifier-key clicks so users can still open destinations in a new tab when appropriate.
Hash Mode
Use hash-based routing for static hosting:
const router = createRouter({
routes: [...],
hash: true, // URLs like /#/about
});Base Path
Prefix all routes with a base path:
const router = createRouter({
routes: [...],
base: '/app', // Routes are relative to /app
});Scroll restoration
Restore the user's previous scroll position on back/forward navigation by enabling scrollRestoration.
const router = createRouter({
routes: [...],
scrollRestoration: true,
});Lazy Loading
Components can be loaded lazily:
const router = createRouter({
routes: [
{
path: '/dashboard',
component: async () => {
const module = await import('./pages/Dashboard');
return module.default;
},
},
],
});Nested Routes
Define child routes for complex layouts:
const router = createRouter({
routes: [
{
path: '/dashboard',
component: () => import('./Dashboard'),
children: [
{ path: '/settings', component: () => import('./Settings') },
{ path: '/profile', component: () => import('./Profile') },
],
},
],
});
// Results in:
// /dashboard -> Dashboard
// /dashboard/settings -> Settings
// /dashboard/profile -> ProfileDynamic Route Management
Routes can be added, replaced, and removed at runtime by name:
router.addRoute(undefined, {
path: '/labs',
name: 'labs',
component: () => import('./Labs'),
});
if (router.hasRoute('labs')) {
console.log(router.resolveRoute({ name: 'labs' }).path);
}
router.addRoute('labs', {
path: '/reports',
name: 'labs-reports',
component: () => import('./LabsReports'),
});
router.removeRoute('labs-reports');After route changes, the router rebuilds its internal route table and re-matches the current URL so reactive consumers see the updated match immediately.
Cleanup
Destroy the router when no longer needed:
router.destroy();Type Reference
type Route = {
path: string;
params: Record<string, string>;
query: Record<string, string | string[]>;
matched: RouteDefinition | null;
hash: string;
};
type RouteDefinition = {
path: string;
component: () => unknown | Promise<unknown>;
name?: string;
meta?: Record<string, unknown>;
children?: RouteDefinition[];
};
type NavigationResult = {
status: 'completed' | 'canceled' | 'redirected' | 'error';
requestedPath: string;
to?: Route;
from?: Route;
error?: unknown;
};
type ResolvedRouteInfo = {
path: string;
href: string;
matched: RouteDefinition | null;
};
type RouterOptions = {
routes: RouteDefinition[];
base?: string;
hash?: boolean;
scrollRestoration?: boolean;
};
type NavigationGuard = (to: Route, from: Route) => boolean | void | Promise<boolean | void>;Pitfalls and gotchas
- Guards run sequentially — returning a
Promiseblocks navigation until it resolves. UsebeforeResolvefor non-blocking work. pushResult()/replaceResult()return aNavigationResultdescribing whether navigation succeeded, was redirected, or cancelled — check it instead of assuming success.- Dynamic route ops (
addRoute,removeRoute,hasRoute) take effect immediately; existing matches are not retroactively re-evaluated. resolveRoute()does not navigate — it returns the matched route descriptor for previewing links or generating URLs.isReady()resolves once the initial navigation finishes; use it to delay app mounting until the route is known.
Performance notes
- Lazy routes (
component: () => import(...)) are code-split automatically; pair withwhenIdle()to prefetch likely next routes. - Cache route-derived data in a store so guards do not refetch on every navigation.
Testing this module
@bquery/bquery/testingexposesmockRouter()(signal-driven, nowindow.history) anduseNavigation()mocks.- Assert
lastNavigationandNavigationResultvalues directly.
Related modules
- Store — share navigation-derived state across the app.
- SSR — route resolution during server rendering.
- View —
bq-on:clickwithrouter.pushfor declarative links.
Version history
- 1.14.0 —
NavigationResult,pushResult/replaceResult,beforeResolve,resolveRoute, dynamicaddRoute/removeRoute/hasRoute,isReady,lastNavigation,useNavigation.