Skip to content

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.

ts
import { createRouter, navigate, currentRoute, isNavigating } from '@bquery/bquery/router';
import { effect } from '@bquery/bquery/reactive';

Basic Setup

ts
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);
});
ts
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():

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

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

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

ts
// 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'] }

beforeEach

Run logic before every navigation. Return false to cancel:

ts
router.beforeEach((to, from) => {
  if (to.path === '/admin' && !isAuthenticated()) {
    navigate('/login');
    return false;
  }
});

afterEach

Run logic after successful navigation:

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

ts
router.beforeResolve(async (to) => {
  if (to.path.startsWith('/billing')) {
    const allowed = await canOpenBilling();
    return allowed || false;
  }
});

Removing Guards

Both methods return a cleanup function:

ts
const removeGuard = router.beforeEach((to, from) => {
  // ...
});

// Later
removeGuard();

beforeEnter

Individual routes can enforce route-local guards before global beforeEach logic finishes navigation.

ts
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.

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

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

ts
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 mode

Use isNavigating to reactively track in-flight navigation, including async guards and redirect resolution.

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

ts
import { useNavigation } from '@bquery/bquery/router';

const { isNavigating, lastNavigation, status, error } = useNavigation();

effect(() => {
  console.log(isNavigating.value, status.value, lastNavigation.value, error.value);
});
ts
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.

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

ts
await router.isReady();
console.log(router.currentRoute.value.path);
ts
import { link } from '@bquery/bquery/router';
import { $ } from '@bquery/bquery/core';

$('#nav-home').on('click', link('/'));
$('#nav-about').on('click', link('/about'));

Intercept all internal links in a container:

ts
import { interceptLinks } from '@bquery/bquery/router';

// Intercept all links in document
const cleanup = interceptLinks(document.body);

// Links with target, download, or external URLs are ignored

Register the custom element once, then use it directly in templates or static HTML.

ts
import { registerBqLink } from '@bquery/bquery/router';

registerBqLink();
html
<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:

ts
const router = createRouter({
  routes: [...],
  hash: true, // URLs like /#/about
});

Base Path

Prefix all routes with a base path:

ts
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.

ts
const router = createRouter({
  routes: [...],
  scrollRestoration: true,
});

Lazy Loading

Components can be loaded lazily:

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

ts
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 -> Profile

Dynamic Route Management

Routes can be added, replaced, and removed at runtime by name:

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

ts
router.destroy();

Type Reference

ts
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 Promise blocks navigation until it resolves. Use beforeResolve for non-blocking work.
  • pushResult() / replaceResult() return a NavigationResult describing 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 with whenIdle() 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/testing exposes mockRouter() (signal-driven, no window.history) and useNavigation() mocks.
  • Assert lastNavigation and NavigationResult values directly.
  • Store — share navigation-derived state across the app.
  • SSR — route resolution during server rendering.
  • Viewbq-on:click with router.push for declarative links.

Version history

  • 1.14.0NavigationResult, pushResult / replaceResult, beforeResolve, resolveRoute, dynamic addRoute / removeRoute / hasRoute, isReady, lastNavigation, useNavigation.

Released under the MIT License.