A composable, type-safe router built on my parser combinator library Combi Parse that thinks in trees. Routes are defined functionally and composed by reference, creating natural hierarchies that mirror your application structure.
npm install @doeixd/combi-router @doeixd/combi-parse zod
Combi-Router is built on @doeixd/combi-parse
for robust URL parsing and uses zod
for powerful, type-safe parameter validation.
Β Β π Type-Safe & Composable
Β Β Β Β Β Β Β Build routes functionally and compose them by reference for perfect type safety and effortless refactoring.
Β Β π³ Hierarchical & Introspective
Β Β Β Β Β Β Β Routes create natural trees that mirror your app's structure, with built-in utilities to analyze the hierarchy.
Β Β β‘ Powerful Data Loading
Β Β Β Β Β Β Β Run data loaders for nested routes in parallel, with an advanced resource system featuring Suspense, caching, retries, and invalidation.
Β Β π§© Composable Layer Architecture
Β Β Β Β Β Β Β Build your ideal router by mixing and matching feature layers (data, performance, dev tools) or creating your own.
Β Β π‘οΈ Advanced Navigation & Guards
Β Β Β Β Β Β Β Navigate with detailed results, cancellation support, and robust, type-safe route guards for fine-grained access control.
Β Β π Integrated SEO & Head Management
Β Β Β Β Β Β Β Dynamically manage document head tags, including titles, meta descriptions, and social cards, directly from your route definitions.
Β Β βοΈ Tree-Shakeable & Modular
Β Β Β Β Β Β Β A modular design ensures you only bundle the features you use, keeping your app lean and fast.
Β Β π οΈ Superior Developer Experience
Β Β Β Β Β Β Β Get dev-mode warnings, advanced debugging utilities, and detailed route analysis right out of the box.
Let's start simple and build up your understanding step by step.
A route in Combi-Router is a blueprint that describes a URL's structure and behavior.
import { route, path } from '@doeixd/combi-router';
// This route matches the exact path "/users"
export const usersRoute = route(path('users'));
The route()
function creates a new route from matchers. Matchers are small building blocks that each handle one part of a URL.
Why export routes? Routes are first-class objects you'll reference throughout your app for navigation, so treating them as exportable values makes them reusable and type-safe.
import { route, path, param } from '@doeixd/combi-router';
import { z } from 'zod';
// Static path segment
export const aboutRoute = route(path('about')); // matches "/about"
// Dynamic parameter with validation
export const userRoute = route(
path('users'),
param('id', z.number()) // matches "/users/123" -> params.id is a number
);
Why validation? URLs are just strings. By validating during route matching, you catch errors early and get proper TypeScript types for your parameters.
The real power comes from composing routes by reference. Instead of redefining common parts, you extend
existing routes:
import { extend } from '@doeixd/combi-router';
// Base route
export const dashboardRoute = route(path('dashboard'));
// Extend the base route
export const usersRoute = extend(dashboardRoute, path('users'));
export const userRoute = extend(usersRoute, param('id', z.number()));
// This creates a natural tree:
// /dashboard <- dashboardRoute
// /dashboard/users <- usersRoute
// /dashboard/users/123 <- userRoute
Why extend? When you change the base route (e.g., to /admin
), all extended routes automatically update. Your route structure mirrors your application structure.
Enhance routes with additional behavior using pipe()
and higher-order functions:
import { meta, loader, layout, pipe } from '@doeixd/combi-router';
export const enhancedUserRoute = pipe(
userRoute,
meta({ title: 'User Profile' }),
loader(async ({ params }) => {
const user = await fetchUser(params.id);
return { user };
}),
layout(ProfileLayout)
);
Why higher-order functions? They're composable and reusable. You can create your own enhancers and mix them with built-in ones.
Once you have routes, create a router instance from an array of all your routes:
import { createRouter } from '@doeixd/combi-router';
const router = createRouter([
dashboardRoute,
usersRoute,
enhancedUserRoute
]);
// Reference-based navigation with detailed results
const result = await router.navigate(enhancedUserRoute, { id: 123 });
if (result.success) {
console.log('Navigation successful');
} else {
console.error('Navigation failed:', result.error);
}
// Simple navigation for backward compatibility
const success = await router.navigateSimple(enhancedUserRoute, { id: 123 });
// Type-safe URL building
const userUrl = router.build(enhancedUserRoute, { id: 123 }); // "/dashboard/users/123"
Why route references? Using actual route objects instead of string names provides perfect type inference and makes refactoring safe. TypeScript knows exactly what parameters each route needs.
Routes now provide powerful introspection capabilities to analyze their structure:
import { route, extend, path, param } from '@doeixd/combi-router';
import { z } from 'zod';
const dashboardRoute = route(path('dashboard'));
const usersRoute = extend(dashboardRoute, path('users'));
const userRoute = extend(usersRoute, param('id', z.number()));
// Analyze route structure
console.log(userRoute.depth); // 2 (dashboard -> users -> user)
console.log(userRoute.ancestors); // [dashboardRoute, usersRoute]
console.log(userRoute.staticPath); // "/dashboard/users"
console.log(userRoute.paramNames); // ["id"]
console.log(userRoute.isDynamic); // true
console.log(userRoute.routeChain); // [dashboardRoute, usersRoute, userRoute]
Routes are now validated when created, catching common configuration errors early:
import { RouteValidationError } from '@doeixd/combi-router';
try {
// This will throw if there are duplicate parameter names
const problematicRoute = extend(
route(param('id', z.string())),
param('id', z.number()) // Error: Duplicate parameter name 'id'
);
} catch (error) {
if (error instanceof RouteValidationError) {
console.error('Route configuration error:', error.message);
}
}
Routes maintain explicit parent-child relationships for better debugging and tooling:
console.log(userRoute.parent === usersRoute); // true
console.log(usersRoute.parent === dashboardRoute); // true
console.log(dashboardRoute.parent); // null (root route)
// Walk up the hierarchy
let current = userRoute;
while (current) {
console.log(current.staticPath);
current = current.parent;
}
// Output: "/dashboard/users", "/dashboard", "/"
Matchers are the building blocks of routes. Each matcher handles one aspect of URL parsing:
// Path segments
path('users') // matches "/users"
path.optional('category') // matches "/category" or ""
path.wildcard('segments') // matches "/any/number/of/segments"
// Parameters with validation
param('id', z.number()) // matches "/123" and validates as number
param('slug', z.string().min(3)) // matches "/hello" with minimum length
// Query parameters
query('page', z.number().default(1)) // matches "?page=5"
query.optional('search', z.string()) // matches "?search=term"
// Other components
end // ensures no remaining path segments
// subdomain(...) and hash(...) can be added with similar patterns
Routes are composed functionally using extend()
:
export const apiRoute = route(path('api'), path('v1'));
export const usersRoute = extend(apiRoute, path('users'));
export const userRoute = extend(usersRoute, param('id', z.number()));
// userRoute now matches /api/v1/users/123
Parameters from parent routes are automatically inherited and merged into a single params
object.
Enhance routes with additional functionality:
import { pipe, meta, loader, guard, cache, lazy } from '@doeixd/combi-router';
export const userRoute = pipe(
route(path('users'), param('id', z.number())),
meta({ title: (params) => `User ${params.id}` }),
loader(async ({ params }) => ({ user: await fetchUser(params.id) })),
guard(async () => await isAuthenticated() || '/login'),
cache({ ttl: 5 * 60 * 1000 }), // Cache for 5 minutes
lazy(() => import('./UserProfile'))
);
Combi-Router now features a modular architecture optimized for tree-shaking and selective feature adoption.
// Core routing functionality (always included)
import { route, extend, createRouter } from '@doeixd/combi-router';
// Advanced data loading and caching
import { createAdvancedResource, resourceState } from '@doeixd/combi-router/data';
// Production features and optimizations
import {
PerformanceManager,
ScrollRestorationManager,
TransitionManager
} from '@doeixd/combi-router/features';
// Development tools and debugging
import {
createWarningSystem,
analyzeRoutes,
DebugUtils
} from '@doeixd/combi-router/dev';
// Framework-agnostic utilities
import {
createLink,
createActiveLink,
createOutlet
} from '@doeixd/combi-router/utils';
Essential routing functionality including route definition, matching, navigation, and basic data loading.
import {
route, extend, path, param, query,
createRouter, pipe, meta, loader, guard
} from '@doeixd/combi-router';
Advanced resource management with caching, retry logic, and global state management.
import {
createAdvancedResource,
resourceState,
globalCache
} from '@doeixd/combi-router/data';
// Enhanced resource with retry and caching
const userResource = createAdvancedResource(
() => api.fetchUser(userId),
{
retry: { attempts: 3 },
cache: { ttl: 300000, invalidateOn: ['user'] },
staleTime: 60000,
backgroundRefetch: true
}
);
Production-ready features for performance optimization and user experience.
import {
PerformanceManager,
ScrollRestorationManager,
TransitionManager,
CodeSplittingManager
} from '@doeixd/combi-router/features';
// Initialize performance monitoring
const performanceManager = new PerformanceManager({
prefetchOnHover: true,
prefetchViewport: true,
enablePerformanceMonitoring: true,
connectionAware: true
});
Development tools for debugging and route analysis.
import {
createWarningSystem,
analyzeRoutes,
DebugUtils,
ConflictDetector
} from '@doeixd/combi-router/dev';
// Create warning system for development
const warningSystem = createWarningSystem(router, {
runtimeWarnings: true,
performanceWarnings: true
});
// Quick route analysis
analyzeRoutes(router);
Framework-agnostic utilities for DOM integration.
import {
createLink,
createActiveLink,
createOutlet,
createMatcher,
createRouterStore
} from '@doeixd/combi-router/utils';
The modular architecture enables significant bundle size optimization:
// Minimal bundle - only core routing
import { route, extend, createRouter } from '@doeixd/combi-router';
// With advanced resources
import { createAdvancedResource } from '@doeixd/combi-router/data';
// With production features
import { PerformanceManager } from '@doeixd/combi-router/features';
// Development tools (excluded in production)
import { createWarningSystem } from '@doeixd/combi-router/dev';
// (dev only)
The new resource system provides production-ready data loading with advanced features.
import { createResource } from '@doeixd/combi-router';
// Simple suspense-based resource
const userRoute = pipe(
route(path('users'), param('id', z.number())),
loader(({ params }) => ({
user: createResource(() => fetchUser(params.id)),
posts: createResource(() => fetchUserPosts(params.id))
}))
);
// In your component
function UserProfile() {
const { user, posts } = router.currentMatch.data;
// These will suspend until data is ready
const userData = user.read();
const postsData = posts.read();
return <div>...</div>;
}
import { createAdvancedResource, resourceState } from '@doeixd/combi-router/data';
// Enhanced resource with all features
const userResource = createAdvancedResource(
() => api.fetchUser(userId),
{
// Retry configuration with exponential backoff
retry: {
attempts: 3,
delay: (attempt) => Math.min(1000 * Math.pow(2, attempt - 1), 10000),
shouldRetry: (error) => error.status >= 500,
onRetry: (error, attempt) => console.log(`Retry ${attempt}:`, error)
},
// Caching with tags for invalidation
cache: {
ttl: 300000, // 5 minutes
invalidateOn: ['user', 'profile'],
priority: 'high'
},
// Stale-while-revalidate behavior
staleTime: 60000, // 1 minute
backgroundRefetch: true
}
);
// Check state without suspending
if (userResource.isLoading) {
console.log('Loading user...');
}
// Non-suspending peek at cached data
const cachedUser = userResource.peek();
if (cachedUser) {
console.log('Cached user:', cachedUser);
}
// Force refresh
await userResource.refetch();
// Invalidate resource
userResource.invalidate();
import { resourceState } from '@doeixd/combi-router/data';
// Global resource state monitoring
const globalState = resourceState.getGlobalState();
console.log('Loading resources:', globalState.loadingCount);
// Event system for observability
const unsubscribe = resourceState.onEvent((event) => {
switch (event.type) {
case 'fetch-start':
console.log('Started loading:', event.resource);
break;
case 'fetch-success':
console.log('Loaded successfully:', event.data);
break;
case 'fetch-error':
console.error('Loading failed:', event.error);
break;
case 'retry':
console.log(`Retry attempt ${event.attempt}:`, event.error);
break;
}
});
// Cache invalidation by tags
resourceState.invalidateByTags(['user', 'profile']);
import { PerformanceManager } from '@doeixd/combi-router/features';
const performanceManager = new PerformanceManager({
// Prefetch on hover with delay
prefetchOnHover: true,
// Prefetch when links enter viewport
prefetchViewport: true,
// Adjust behavior based on connection
connectionAware: true,
// Monitor performance metrics
enablePerformanceMonitoring: true,
// Preload critical routes immediately
preloadCriticalRoutes: ['dashboard', 'user-profile'],
// Memory management
memoryManagement: {
enabled: true,
maxCacheSize: 50,
maxCacheAge: 30 * 60 * 1000,
cleanupInterval: 5 * 60 * 1000
}
});
// Setup hover prefetching for a link
const cleanup = performanceManager.setupHoverPrefetch(linkElement, 'user-route');
// Setup viewport prefetching
const cleanupViewport = performanceManager.setupViewportPrefetch(linkElement, 'user-route');
// Get performance report
const report = performanceManager.getPerformanceReport();
console.log('Prefetch hit rate:', report.prefetchHitRate);
import { ScrollRestorationManager } from '@doeixd/combi-router/features';
const scrollManager = new ScrollRestorationManager({
enabled: true,
restoreOnBack: true,
restoreOnForward: true,
saveScrollState: true,
smoothScrolling: true,
scrollBehavior: 'smooth',
debounceTime: 100,
// Advanced configuration
customScrollContainer: '#main-content',
excludeRoutes: ['modal-routes'],
persistScrollState: true
});
// Manual scroll position management
scrollManager.saveScrollPosition(routeId);
scrollManager.restoreScrollPosition(routeId);
scrollManager.scrollToTop();
scrollManager.scrollToElement('#section');
import { TransitionManager } from '@doeixd/combi-router/features';
const transitionManager = new TransitionManager({
enabled: true,
duration: 300,
easing: 'ease-in-out',
type: 'fade',
// Per-route transition configuration
routeTransitions: {
'user-profile': { type: 'slide-left', duration: 400 },
'settings': { type: 'fade', duration: 200 }
},
// Custom transition classes
transitionClasses: {
enter: 'page-enter',
enterActive: 'page-enter-active',
exit: 'page-exit',
exitActive: 'page-exit-active'
}
});
// Manual transition control
await transitionManager.performTransition(fromRoute, toRoute, {
direction: 'forward',
customData: { userId: 123 }
});
import { createWarningSystem, analyzeRoutes } from '@doeixd/combi-router/dev';
// Create comprehensive warning system
const warningSystem = createWarningSystem(router, {
runtimeWarnings: true,
staticWarnings: true,
performanceWarnings: true,
severityFilter: ['warning', 'error']
});
// Quick route analysis
analyzeRoutes(router);
// Get warnings programmatically
const warnings = warningSystem.getWarnings();
const conflictWarnings = warningSystem.getWarningsByType('conflicting-routes');
const errorWarnings = warningSystem.getWarningsBySeverity('error');
import { DebugUtils } from '@doeixd/combi-router/dev';
// Route structure debugging
DebugUtils.logRouteTree(router);
DebugUtils.analyzeRoutePerformance(router);
DebugUtils.checkRouteConflicts(router);
// Navigation debugging
DebugUtils.enableNavigationLogging(router);
DebugUtils.logMatchDetails(currentMatch);
// Performance debugging
DebugUtils.enablePerformanceMonitoring(router);
const metrics = DebugUtils.getPerformanceMetrics();
import { NavigationErrorType } from '@doeixd/combi-router';
const result = await router.navigate(userRoute, { id: 123 });
if (!result.success) {
switch (result.error?.type) {
case NavigationErrorType.RouteNotFound:
console.error('Route not found');
break;
case NavigationErrorType.GuardRejected:
console.error('Navigation blocked:', result.error.message);
break;
case NavigationErrorType.LoaderFailed:
console.error('Data loading failed:', result.error.originalError);
break;
case NavigationErrorType.ValidationFailed:
console.error('Parameter validation failed');
break;
case NavigationErrorType.Cancelled:
console.log('Navigation was cancelled');
break;
}
}
Before:
import { createRouter, createResource, createLink } from '@doeixd/combi-router';
After:
// Core functionality
import { createRouter } from '@doeixd/combi-router';
// Advanced resources (optional)
import { createAdvancedResource } from '@doeixd/combi-router/data';
// Utilities (optional)
import { createLink } from '@doeixd/combi-router/utils';
Before:
const resource = createResource(() => fetchUser(id));
After:
// Simple resource (same API)
const resource = createResource(() => fetchUser(id));
// Or enhanced resource with more features
const resource = createAdvancedResource(
() => fetchUser(id),
{
retry: { attempts: 3 },
cache: { ttl: 300000 },
staleTime: 60000
}
);
The navigation API is fully backward compatible. Enhanced error handling is opt-in:
// Old way (still works)
const success = await router.navigateSimple(route, params);
// New way (detailed error information)
const result = await router.navigate(route, params);
if (result.success) {
// Handle success
} else {
// Handle specific error types
}
The head management module provides comprehensive document head tag management with support for dynamic content, SEO optimization, and server-side rendering.
import { head, seoMeta } from '@doeixd/combi-router/features';
// Static head data
const aboutRoute = pipe(
route(path('about')),
head({
title: 'About Us',
meta: [
{ name: 'description', content: 'Learn more about our company' },
{ name: 'keywords', content: 'about, company, team' }
],
link: [
{ rel: 'canonical', href: 'https://example.com/about' }
]
})
);
// Dynamic head data based on route parameters
const userRoute = pipe(
route(path('users'), param('id', z.number())),
head(({ params }) => ({
title: `User Profile - ${params.id}`,
meta: [
{ name: 'description', content: `Profile page for user ${params.id}` }
]
}))
);
// Complete SEO setup with Open Graph and Twitter Cards
const productRoute = pipe(
route(path('products'), param('id', z.number())),
head(({ params }) => ({
title: `Product ${params.id}`,
titleTemplate: 'Store | %s', // Results in: "Store | Product 123"
// Basic SEO
...seoMeta.basic({
description: `Amazing product ${params.id}`,
keywords: ['product', 'store', 'shopping'],
robots: 'index,follow'
}),
// Open Graph tags
...seoMeta.og({
title: `Product ${params.id}`,
description: 'The best product you will ever buy',
image: `https://example.com/products/${params.id}/image.jpg`,
url: `https://example.com/products/${params.id}`,
type: 'product'
}),
// Twitter Cards
...seoMeta.twitter({
card: 'summary_large_image',
title: `Product ${params.id}`,
description: 'An amazing product',
image: `https://example.com/products/${params.id}/twitter.jpg`
})
}))
);
// Scripts, styles, and HTML attributes
const dashboardRoute = pipe(
route(path('dashboard')),
head({
title: 'Dashboard',
script: [
{ src: 'https://analytics.example.com/track.js', async: true },
{ innerHTML: 'window.config = { theme: "dark" };' }
],
style: [
{ innerHTML: 'body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }' }
],
htmlAttrs: { lang: 'en', 'data-theme': 'dark' },
bodyAttrs: { class: 'dashboard dark-mode' }
})
);
import { HeadManager, resolveHeadData } from '@doeixd/combi-router/features';
// Initialize head manager
const headManager = new HeadManager(document);
// Update head tags on navigation
router.onNavigate((match) => {
if (match?.route._head) {
const resolvedHead = resolveHeadData(match.route._head, match);
headManager.apply(resolvedHead);
}
});
For complete documentation, see Head Management Guide.
The navigate()
method now returns a NavigationResult
object with comprehensive information about the navigation attempt:
import { NavigationErrorType } from '@doeixd/combi-router';
const result = await router.navigate(userRoute, { id: 123 });
if (result.success) {
console.log('Navigation completed successfully');
console.log('Active match:', result.match);
} else {
// Handle different types of navigation errors
switch (result.error?.type) {
case NavigationErrorType.RouteNotFound:
console.error('Route not found');
break;
case NavigationErrorType.GuardRejected:
console.error('Navigation blocked by guard:', result.error.message);
break;
case NavigationErrorType.LoaderFailed:
console.error('Data loading failed:', result.error.originalError);
break;
case NavigationErrorType.ValidationFailed:
console.error('Parameter validation failed');
break;
case NavigationErrorType.Cancelled:
console.log('Navigation was cancelled');
break;
}
}
Long-running navigations can now be cancelled, which is especially useful for preventing race conditions:
// Start a navigation and get a controller
const controller = router.currentNavigation;
if (controller) {
console.log('Navigating to:', controller.route);
// Cancel the navigation if needed
setTimeout(() => {
if (!controller.cancelled) {
controller.cancel();
console.log('Navigation cancelled');
}
}, 1000);
// Wait for the result
const result = await controller.promise;
if (result.cancelled) {
console.log('Navigation was cancelled');
}
}
For simple use cases, the navigateSimple()
method provides the traditional boolean return value:
// Simple boolean result for straightforward cases
const success = await router.navigateSimple(userRoute, { id: 123 });
if (success) {
console.log('Navigation successful');
} else {
console.log('Navigation failed');
}
// Still get full details when needed
const detailedResult = await router.navigate(userRoute, { id: 123 });
The new typedGuard()
function provides better type safety and more context for route protection:
import { typedGuard, GuardContext } from '@doeixd/combi-router';
import { z } from 'zod';
// Define a route with parameters
const adminUserRoute = route(
path('admin'),
path('users'),
param('userId', z.string())
);
// Create a typed guard with full context access
const adminGuard = typedGuard<{ userId: string }>(({ params, to, from, searchParams }) => {
// Full type safety on params
const userId = params.userId; // TypeScript knows this is a string
// Access to route context
console.log('Navigating to:', to.url);
console.log('Coming from:', from?.url || 'initial load');
console.log('Search params:', searchParams.get('redirect'));
// Return boolean for allow/deny or string for redirect
if (!isCurrentUserAdmin()) {
return '/login?redirect=' + encodeURIComponent(to.url);
}
// Additional validation based on the user ID
if (!canAccessUser(userId)) {
return false; // Block navigation
}
return true; // Allow navigation
});
// Apply the guard to the route
const protectedRoute = pipe(
adminUserRoute,
guard(adminGuard)
);
When a nested route like /dashboard/users/123
is matched, Combi-Router builds a tree of match objects. If both dashboardRoute
and userRoute
have a loader
, they are executed in parallel, and you can access data from any level of the hierarchy.
// dashboard-layout.ts
const dashboardRoute = pipe(
route(path('dashboard')),
loader(async () => ({ stats: await fetchDashboardStats() })),
layout(DashboardLayout) // Layout component with <Outlet />
);
// user-profile.ts
const userRoute = pipe(
extend(dashboardRoute, path('users'), param('id', z.number())),
loader(async ({ params }) => ({ user: await fetchUser(params.id) }))
);
// In your view for the user route, you can access both sets of data:
const dashboardData = router.currentMatch.data; // { stats: ... }
const userData = router.currentMatch.child.data; // { user: ... }
Improve perceived performance by loading a route's code and data before the user clicks a link. The router.peek()
method is perfect for this.
// Preload on hover to make navigation feel instantaneous
myLink.addEventListener('mouseenter', () => {
router.peek(userRoute, { id: 123 });
});
// Navigate as usual on click
myLink.addEventListener('click', (e) => {
e.preventDefault();
router.navigate(userRoute, { id: 123 });
});
Combi-Router automatically uses the browser's native View Transitions API for smooth, app-like page transitions. To enable it, simply add a CSS view-transition-name
to elements that should animate between pages.
/* On a list page */
.product-thumbnail {
view-transition-name: product-image-123;
}
/* On a detail page */
.product-hero-image {
view-transition-name: product-image-123; /* Same name! */
}
The router handles the rest. No JavaScript changes are needed.
Combi-Router is framework-agnostic at its core. To help you integrate it into a vanilla JavaScript project, we provide a set of utility functions. These helpers bridge the gap between the router's state and the DOM, making it easy to create navigable links, render nested views, and react to route changes.
Creates a fully functional <a>
element that navigates using the router. It automatically sets the href
and intercepts click events to trigger client-side navigation. Each created link comes with a destroy
function to clean up its event listeners.
import { createLink } from '@doeixd/combi-router/utils';
const { element, destroy } = createLink(
router,
userRoute,
{ id: 123 },
{ children: 'View Profile', className: 'btn' }
);
document.body.appendChild(element);
// Later, when the element is removed from the DOM:
// destroy();
Builds on createLink
to create an <a>
element that automatically updates its CSS class when its route is active. This is perfect for navigation menus.
activeClassName
: The CSS class to apply when the link is active.exact
: Iftrue
, the class is applied only on an exact route match. Iffalse
(default), it's also applied for any active child routes.
import { createActiveLink } from '@doeixd/combi-router/utils';
const { element } = createActiveLink(router, dashboardRoute, {}, {
children: 'Dashboard',
className: 'nav-link',
activeClassName: 'font-bold' // Applied on /dashboard, /dashboard/users, etc.
});
document.querySelector('nav').appendChild(element);
Makes any existing HTML element navigable. This is useful for turning buttons, divs, or other non-anchor elements into type-safe navigation triggers.
import { attachNavigator } from '@doeixd/combi-router/utils';
const myButton = document.getElementById('home-button');
const { destroy } = attachNavigator(myButton, router, homeRoute, {});
Provides a declarative "outlet" for nested routing, similar to <Outlet>
in React Router or <router-view>
in Vue. It listens for route changes and renders the correct child view into a specified container element.
parentRoute
: The route of the component that contains the outlet.container
: The DOM element where child views will be rendered.viewMap
: An object mappingRoute.id
to anElementFactory
function(match) => Node
.
// In your dashboard layout component
import { createOutlet } from '@doeixd/combi-router/utils';
import { dashboardRoute, usersRoute, settingsRoute } from './routes';
import { UserListPage, SettingsPage } from './views';
const outletContainer = document.querySelector('#outlet');
createOutlet(router, dashboardRoute, outletContainer, {
[usersRoute.id]: (match) => new UserListPage(match.data), // Pass data to the view
[settingsRoute.id]: () => new SettingsPage(),
});
Creates a fluent, type-safe conditional tool that reacts to route changes. It's a powerful way to implement declarative logic that isn't tied directly to rendering.
import { createMatcher } from '@doeixd/combi-router/utils';
// Update the document title based on the active route
createMatcher(router)
.when(homeRoute, () => {
document.title = 'My App | Home';
})
.when(userRoute, (match) => {
document.title = `Profile for User ${match.params.id}`;
})
.otherwise(() => {
document.title = 'My App';
});
Creates a minimal, framework-agnostic reactive store for the router's state (currentMatch
, isNavigating
, isFetching
). This is useful for integrating with UI libraries or building your own reactive logic in vanilla JS.
import { createRouterStore } from '@doeixd/combi-router/utils';
const store = createRouterStore(router);
const unsubscribe = store.subscribe(() => {
const { isNavigating } = store.getSnapshot();
// Show a global loading indicator while navigating
document.body.style.cursor = isNavigating ? 'wait' : 'default';
});
// To clean up:
// unsubscribe();
For even simpler integration, Combi-Router provides ready-to-use Web Components that handle routing declaratively in your HTML:
<!DOCTYPE html>
<html>
<head>
<script type="module">
// Import standalone components (no setup required!)
import '@doeixd/combi-router/components-standalone';
</script>
</head>
<body>
<!-- Define your routes declaratively -->
<view-area match="/users/:id" view-id="user-detail"></view-area>
<view-area match="/about" view-id="about-page"></view-area>
<!-- Define your templates with automatic head management -->
<template is="view-template" view-id="user-detail">
<!-- Head automatically discovered and linked to view-area -->
<view-head
title="User Profile"
title-template="My App | %s"
description="View user profile and details"
og-title="User Profile"
og-description="Comprehensive user profile page"
og-type="profile">
</view-head>
<h1>User Details</h1>
<p>User ID: <span class="user-id"></span></p>
</template>
<template is="view-template" view-id="about-page">
<!-- Each template can have its own head configuration -->
<view-head
title="About Us"
description="Learn more about our company and mission"
keywords="about, company, mission, team"
canonical="https://myapp.com/about"
og-title="About Our Company"
og-description="Discover our story and values">
</view-head>
<h1>About</h1>
<p>This is the about page.</p>
</template>
<!-- Navigation works automatically -->
<nav>
<a href="/users/123">User 123</a>
<a href="/about">About</a>
</nav>
</body>
</html>
<!-- Nested route structure -->
<view-area match="/dashboard" view-id="dashboard"></view-area>
<view-area match="/dashboard/users" view-id="users-list"></view-area>
<view-area match="/dashboard/users/:id" view-id="user-detail"></view-area>
<!-- Templates with automatic head discovery -->
<template is="view-template" view-id="dashboard">
<!-- Parent template head - automatically merges with child heads -->
<view-head
title="Dashboard"
title-template="Admin | %s"
description="Admin dashboard overview">
</view-head>
<h1>Dashboard</h1>
<nav>
<a href="/dashboard/users">Users</a>
<a href="/dashboard/analytics">Analytics</a>
</nav>
<main class="dashboard-content"></main>
</template>
<template is="view-template" view-id="users-list">
<!-- Child template head - merges with parent -->
<view-head
title="Users"
description="Manage users and permissions"
robots="noindex">
</view-head>
<h2>Users</h2>
<div class="users-grid"></div>
</template>
<!-- External template with dynamic head loading -->
<template is="view-template" view-id="user-detail" src="/views/user-detail.html"></template>
<!-- You can still use manual linking for external head configs -->
<view-head head-id="external-head" src="/head-configs/user-detail.js"></view-head>
<view-area match="/special/:id" view-id="special-view" head-id="external-head"></view-area>
- Zero JavaScript Configuration: Just import and use
- Declarative Routing: Define routes in HTML attributes
- Automatic Navigation: Links work out of the box
- SEO-Ready: Built-in head management with Open Graph and Twitter Cards
- Automatic Head Discovery: Place
view-head
inside templates - no manual linking needed - Nested Head Management: Head tags merge hierarchically for complex layouts
- Dynamic Content: Load head configurations from external modules
- Flexible Linking: Choose automatic discovery or manual
head-id
linking - Progressive Enhancement: Works with or without JavaScript
- Dynamic Route Management: Add/remove routes programmatically when needed
Combi-Router now features a revolutionary layer-based composition system using our custom makeLayered
implementation, enabling true user extensibility while maintaining backwards compatibility.
Traditional routers force you to choose between their built-in features or build everything from scratch. With layers, you can:
- Mix and match built-in features exactly as needed
- Create custom layers for your specific business logic
- Compose layers conditionally based on environment or feature flags
- Build orchestrated systems where layers can call each other's methods
- Maintain type safety with full TypeScript inference across all layers
import {
createLayeredRouter,
createCoreNavigationLayer,
withPerformance,
withScrollRestoration
} from '@doeixd/combi-router';
// Compose exactly the router you need
const router = createLayeredRouter(routes)
(createCoreNavigationLayer()) // Base navigation
(withPerformance({ prefetchOnHover: true })) // Performance optimizations
(withScrollRestoration({ strategy: 'smooth' })) // Scroll management
();
// All layer methods are now available
router.navigate('/user/123');
router.prefetchRoute('about');
router.saveScrollPosition();
Create your own layers for analytics, authentication, or any business logic:
const withAnalytics = (config: { trackingId: string }) => (self: any) => {
// Register lifecycle hooks
if ('_registerLifecycleHook' in self) {
self._registerLifecycleHook('onNavigationStart', (context: any) => {
console.log(`[Analytics] Navigation started: ${context.to?.path}`);
});
self._registerLifecycleHook('onNavigationComplete', (match: any) => {
console.log(`[Analytics] Page view: ${match.path}`);
});
}
return {
trackEvent: (event: string, data?: any) => {
console.log(`[Analytics] Event: ${event}`, data);
},
trackError: (error: Error, context?: any) => {
console.log(`[Analytics] Error: ${error.message}`, context);
}
};
};
// Use your custom layer
const router = createLayeredRouter(routes)
(createCoreNavigationLayer())
(withPerformance())
(withAnalytics({ trackingId: 'GA-123456-7' }))
();
// Your custom methods are now available
router.trackEvent('button_click', { button: 'signup' });
Layers can call methods from previously applied layers, enabling powerful composition patterns:
const withSmartNavigation = (self: any) => ({
// Enhanced navigation that uses multiple layers
smartNavigate: async (path: string, options: any = {}) => {
// Track with analytics (if analytics layer is present)
if ('trackEvent' in self) {
self.trackEvent('navigation_intent', { path });
}
// Save scroll position (if scroll restoration layer is present)
if ('saveScrollPosition' in self) {
self.saveScrollPosition();
}
// Perform the navigation using core layer
const result = await self.navigate(path, options);
if (result && 'trackEvent' in self) {
self.trackEvent('navigation_complete', { path });
}
return result;
}
});
const router = createLayeredRouter(routes)
(createCoreNavigationLayer())
(withPerformance())
(withScrollRestoration())
(withAnalytics({ trackingId: 'GA-123' }))
(withSmartNavigation) // Orchestrates all previous layers
();
// One method that uses multiple layer capabilities
router.smartNavigate('/dashboard');
Apply layers based on environment, feature flags, or any condition:
import { conditionalLayer } from '@doeixd/combi-router';
const isDev = process.env.NODE_ENV === 'development';
const isProd = process.env.NODE_ENV === 'production';
const hasAnalytics = config.features.analytics;
const router = createLayeredRouter(routes)
(createCoreNavigationLayer())
// Only add performance layer in production
(conditionalLayer(isProd, withPerformance({
prefetchOnHover: true,
enablePerformanceMonitoring: true
})))
// Only add debug layer in development
(conditionalLayer(isDev, (self: any) => ({
debug: () => console.log('Router state:', self.currentMatch),
logAllNavigation: true
})))
// Conditional analytics
(conditionalLayer(hasAnalytics, withAnalytics({
trackingId: config.analytics.trackingId
})))
();
- Core Navigation (
createCoreNavigationLayer
): Essential routing functionality - Performance (
withPerformance
): Prefetching, monitoring, memory management - Scroll Restoration (
withScrollRestoration
): Automatic scroll position management - Transitions (
withTransitions
): Smooth page transitions - Code Splitting (
withCodeSplitting
): Dynamic route loading
RouterOptions.features
) is deprecated in favor of the new layer system. The old API continues to work but will be removed in the next major version.
// β Old way (deprecated)
const router = new CombiRouter(routes, {
features: {
performance: { prefetchOnHover: true },
scrollRestoration: { strategy: 'smooth' }
}
});
// β
New way (recommended)
const router = createLayeredRouter(routes)
(createCoreNavigationLayer())
(withPerformance({ prefetchOnHover: true }))
(withScrollRestoration({ strategy: 'smooth' }))
();
The new layer system provides:
- Better tree-shaking: Only bundle layers you use
- User extensibility: Create custom layers for your needs
- Better composition: Mix and match features freely
- Type safety: Full TypeScript inference across layers
- Self-aware layers: Layers can interact with each other
For backwards compatibility, the traditional configuration-based approach still works:
const router = createRouter(
[homeRoute, usersRoute, userRoute], // An array of all routes
{
baseURL: 'https://myapp.com', // For running in a subdirectory
hashMode: false, // Use `/#/path` style URLs
features: { // β οΈ Deprecated - use layer system instead
performance: { prefetchOnHover: true }
}
}
);
// Define a fallback route for any URL that doesn't match
router.fallback(notFoundRoute);
// Define a global error handler for failures during navigation
router.onError(({ error, to, from }) => {
console.error('Navigation error:', error);
// Send to an error tracking service
});
While Combi-Router provides a comprehensive set of built-in matchers like path
, param
, and query
, its true power lies in its composable foundation. The router is designed to be fully extensible, allowing you to create your own custom matchers using the full power of the underlying @doeixd/combi-parse
library.
This is an advanced feature for when you need to parse complex URL structures that go beyond simple static or dynamic segments.
At its core, a matcher is an object that fulfills the RouteMatcher
contract. It tells the router two things:
- How to parse a URL segment: This is done with a
combi-parse
parser. The parser's job is to recognize a part of the URL and, if it captures a value, return it as an object (e.g.,{ myParam: 'value' }
). - How to build a URL segment: This is the inverse operation, handled by a
build
function. Given aparams
object, it constructs the corresponding URL string.
Imagine you have an API that can be versioned, and you want a single route definition to handle both /api/v1/posts
and /api/v2/posts
, capturing the version as a parameter.
You can create a custom version()
matcher to handle this.
// in my-matchers.ts
import { str, choice } from '@doeixd/combi-parse';
import type { RouteMatcher } from '@doeixd/combi-router';
/**
* A custom matcher that recognizes /v1 or /v2 and captures the result.
* @param paramName The name for the captured version parameter.
*/
export function version(paramName: string): RouteMatcher {
// 1. The Parser: Use `choice` to accept 'v1' or 'v2'.
// It must return an object with the parameter name as the key.
const versionParser = str('/')
.keepRight(choice([str('v1'), str('v2')]))
.map(parsedVersion => ({ [paramName]: parsedVersion }));
// 2. The Builder: The inverse of the parser.
const buildFn = (params: Record<string, any>): string | null => {
const apiVersion = params[paramName];
if (apiVersion === 'v1' || apiVersion === 'v2') {
return `/${apiVersion}`;
}
// Return null if the required param is missing or invalid.
return null;
};
// 3. The Contract: Return an object that fulfills the RouteMatcher interface.
return {
type: 'customVersion', // A unique type for debugging
parser: versionParser,
build: buildFn,
paramName: paramName,
};
}
Now, you can import and use version()
in your route definitions just like any built-in matcher.
// in my-routes.ts
import { route, path, param, createRouter } from '@doeixd/combi-router';
import { version } from './my-matchers'; // Import your custom matcher
const postsRoute = route(
path('api'),
version('apiVersion'), // Your custom matcher in action!
path('posts'),
param('id', z.number())
);
const router = createRouter([postsRoute]);
// --- Matching ---
const matchV1 = router.match('/api/v1/posts/123');
// matchV1.params -> { apiVersion: 'v1', id: 123 }
const matchV2 = router.match('/api/v2/posts/456');
// matchV2.params -> { apiVersion: 'v2', id: 456 }
// --- Building ---
const urlV1 = router.build(postsRoute, { apiVersion: 'v1', id: 123 });
// -> "/api/v1/posts/123"
const urlV2 = router.build(postsRoute, { apiVersion: 'v2', id: 456 });
// -> "/api/v2/posts/456"
By creating your own domain-specific matchers, you can build highly expressive, reusable, and type-safe routing grammars that are perfectly tailored to your application's needs.
route(...matchers)
: Creates a new base route.extend(baseRoute, ...matchers)
: Creates a new child route from a base.createRouter(routes, options?)
: Creates the router instance.createResource(promiseFn)
: Wraps an async function in a suspense-ready resource.createAdvancedResource(promiseFn, config?)
: Creates an enhanced resource with retry, caching, and state management.typedGuard<TParams>(guardFn)
: Creates a type-safe guard function with enhanced context.
path(segment)
: Matches a static path segment.path.optional(segment)
: Matches an optional path segment.path.wildcard(name?)
: Matches all remaining path segments into an array.param(name, schema)
: Matches a dynamic parameter with Zod validation.query(name, schema)
: Declares a required query parameter with Zod validation.query.optional(name, schema)
: Declares an optional query parameter.end
: Ensures the path has no remaining segments.
pipe(route, ...enhancers)
: Applies a series of enhancers to a route.meta(metadata)
: Attaches arbitrary metadata to a route.loader(loaderFn)
: Adds a data-loading function to a route.layout(component)
: Associates a layout component with a route.guard(...guardFns)
: Protects a route with one or more guard functions.cache(options)
: Adds caching behavior to a route's loader.lazy(importFn)
: Makes a route's component lazy-loaded.
navigate(route, params)
: Programmatically navigates to a route, returnsPromise<NavigationResult>
.navigateSimple(route, params)
: Simple navigation that returnsPromise<boolean>
for backward compatibility.build(route, params)
: Generates a URL string for a route.match(url)
: Matches a URL and returns the correspondingRouteMatch
tree.peek(route, params)
: Proactively loads a route's code and data.subscribe(listener)
: Subscribes to route changes.addRoute(route)
: Dynamically adds a route to the router.removeRoute(route)
: Dynamically removes a route from the router.cancelNavigation()
: Cancels the current navigation if one is in progress.
currentMatch
: The currently activeRouteMatch
object tree, ornull
.currentNavigation
: The activeNavigationController
if a navigation is in progress, ornull
.isNavigating
: A boolean indicating if a navigation is in progress.isFetching
: A boolean indicating if any route loaders are active.routes
: A flat array of all registered route objects.
route.depth
: The depth of the route in the hierarchy (0 for root routes).route.ancestors
: Array of all ancestor routes from root to parent.route.staticPath
: The static path parts (non-parameter segments).route.paramNames
: Array of all parameter names defined by the route.route.isDynamic
: Boolean indicating if the route has dynamic parameters.route.hasQuery
: Boolean indicating if the route has query parameters.route.routeChain
: Array of routes from root to this route (including this route).route.parent
: The parent route, ornull
for root routes.
RouteValidationError
: Thrown when route validation fails during creation.NavigationErrorType
: Enum of possible navigation error types (RouteNotFound
,GuardRejected
,LoaderFailed
,ValidationFailed
,Cancelled
,Unknown
).NavigationError
: Interface describing detailed navigation error information.NavigationResult
: Interface describing the result of a navigation attempt.NavigationController
: Interface for managing ongoing navigation.GuardContext<TParams>
: Context object passed to typed guard functions.TypedRouteGuard<TParams>
: Type for typed guard functions.
The layered router architecture allows you to compose routers from independent, reusable layers:
import {
createLayeredRouter,
dataLayer,
devLayer,
performanceLayer
} from '@doeixd/combi-router';
// Basic layered router
const router = createLayeredRouter(routes)
(dataLayer()) // Add data management capabilities
(devLayer()) // Add development tools (dev mode only)
(); // Finalize the router
// Advanced configuration
const advancedRouter = createLayeredRouter(routes, {
baseURL: '/app',
hashMode: false
})
(dataLayer({
autoCleanup: true,
cleanupInterval: 300000,
logResourceEvents: true
}))
(devLayer({
exposeToWindow: true,
autoAnalyze: true,
performanceMonitoring: true
}))
(performanceLayer({
prefetchOnHover: true,
prefetchViewport: true,
connectionAware: true
}))
();
The data layer provides advanced data management capabilities:
// Access data layer features
const router = createLayeredRouter(routes)(dataLayer())();
// Advanced caching with tags
router.cache.set('user:123', userData, {
ttl: 300000,
invalidateOn: ['user', 'profile'],
priority: 'high'
});
// Create suspense-compatible resources
const userResource = router.createResource(() =>
fetch(`/api/users/${params.id}`).then(r => r.json())
);
// Advanced resources with retry and caching
const advancedResource = router.createAdvancedResource(
() => api.fetchUser(userId),
{
retry: { attempts: 3 },
cache: { ttl: 300000, invalidateOn: ['user'] },
staleTime: 60000,
backgroundRefetch: true
}
);
// Global resource monitoring
const globalState = router.getGlobalResourceState();
if (globalState.isLoading) {
showLoadingSpinner();
}
// Cache invalidation
router.invalidateByTags(['user', 'profile']);
// Route preloading
router.preloadRoute('user-dashboard', { id: userId });
The development layer provides comprehensive debugging and development tools:
// Access dev tools (development mode only)
const router = createLayeredRouter(routes)(devLayer())();
// Run comprehensive analysis
router.runDevAnalysis();
// Get detailed development report
const report = router.getDevReport();
console.log(`Performance score: ${report.performance?.score}/100`);
console.log(`Found ${report.warnings.length} warnings`);
// Log formatted report
router.logDevReport();
// Export debug data
const debugData = router.exportDevData();
localStorage.setItem('router-debug', debugData);
// Access via window (if exposeToWindow: true)
window.combiRouterDev?.analyze();
window.combiRouterDev?.report();
For common use cases, use the quick setup functions:
import { quickDataLayer, quickDevLayer } from '@doeixd/combi-router';
// Production-ready setup
const router = createLayeredRouter(routes)
(quickDataLayer()) // Optimized data management
(quickDevLayer()) // All dev tools (dev mode only)
();
// Equivalent to full configuration
const router = createLayeredRouter(routes)
(dataLayer({
autoCleanup: true,
cleanupInterval: 300000,
logResourceEvents: process.env.NODE_ENV !== 'production'
}))
(devLayer({
exposeToWindow: true,
autoAnalyze: true,
warnings: true,
conflictDetection: true,
performanceMonitoring: true,
routeValidation: true,
debugMode: true
}))
();
The new layered system is fully backwards compatible:
// Original API still works
const router = new CombiRouter(routes, options);
// Automatically includes:
// - Data layer for resource management
// - Dev layer in development mode
// - All existing functionality
- Perfect Type Safety: Impossible to make typos in route names or pass incorrect parameter types.
- Better IDE Support: Get autocompletion for routes and
go-to-definition
that works. - Confident Refactoring: Rename a route or change its parameters, and TypeScript will instantly show you everywhere that needs to be updated.
- Functional Composition: Routes are first-class values that can be imported, exported, and composed with pure functions.
- Framework Agnostic: The core logic is pure TypeScript, allowing for simple integration with any framework or vanilla JS.
- Tree-Shakable: Import only the features you need for optimal bundle size.
- Production Ready: Built-in performance optimizations, error handling, and monitoring.
Combi-Router is designed for performance with several optimization strategies:
- Core: ~12KB gzipped (essential routing functionality)
- +Data: ~4KB gzipped (advanced resources and caching)
- +Features: ~6KB gzipped (performance optimizations)
- +Utils: ~3KB gzipped (DOM utilities)
- Dev Tools: ~3KB gzipped (excluded in production builds)
- Tree-shaking optimized: Only bundle what you use
- Lazy route loading: Code splitting at the route level
- Intelligent prefetching: Connection-aware prefetching strategies
- Memory management: Automatic cleanup of unused cache entries
- Performance monitoring: Built-in Web Vitals tracking
- Use modular imports to minimize bundle size
- Enable connection-aware prefetching for mobile users
- Configure cache TTL based on data volatility
- Use scroll restoration for better UX
- Enable performance monitoring in development
We welcome contributions! Please see our Contributing Guide for details.
MIT License - see LICENSE file for details.