Version: 1.0.0-rc.10 Type: Client-Side Routing Plugin Bundle Size: ~13KB minified Dependencies: Eleva.js core
The Router Plugin is a powerful, reactive, and fully extensible routing solution for Eleva.js. It provides client-side navigation with support for multiple routing modes, navigation guards, lazy loading, layouts, and a comprehensive plugin system.
import { Eleva } from "eleva";
import { Router } from "eleva/plugins";
const app = new Eleva("myApp");
const router = app.use(Router, {
mode: "hash",
mount: "#app",
routes: [
{ path: "/", component: HomePage },
{ path: "/users/:id", component: UserPage },
{ path: "*", component: NotFoundPage }
]
});
await router.start();
// Navigate
await router.navigate("/users/123");
await router.navigate({ path: "/users/:id", params: { id: "123" } });
// Access current route (reactive)
router.currentRoute.value // Full route info
router.currentParams.value // { id: "123" }
router.currentQuery.value // { tab: "settings" }
// Watch for changes
router.currentRoute.watch((route) => console.log(route.path));
// Add guard
const unsub = router.onBeforeEach((to, from) => {
if (to.meta.requiresAuth && !isLoggedIn()) return "/login";
});
// Stop router
await router.stop();
{ path: "/", component: HomePage } // Static route
{ path: "/users/:id", component: UserPage } // Dynamic param
{ path: "/posts/:category/:slug", component: PostPage } // Multiple params
{ path: "*", component: NotFoundPage } // Catch-all (404)
{ path: "/admin", component: () => import("./Admin.js") } // Lazy loaded
{ path: "/dashboard", component: Page, layout: Layout } // With layout
{ path: "/settings", component: Page, meta: { auth: true } } // With metadata
| Return | Effect |
|——–|——–|
| true / undefined | Allow navigation |
| false | Block navigation |
| "/path" | Redirect to path |
| { path, query, ... } | Redirect with options |
| Event | Can Block | Use Case |
|——-|———–|———-|
| router:beforeEach | Yes | Auth guards, logging |
| router:beforeResolve | Yes | Loading indicators |
| router:afterResolve | No | Hide loading |
| router:beforeRender | No | Page transitions |
| router:afterRender | No | Animations |
| router:scroll | No | Scroll restoration |
| router:afterEach | No | Analytics |
| router:onError | No | Error reporting |
| Feature | Description |
|---|---|
| Multiple Routing Modes | Hash (/#/path), History (/path), or Query (?view=/path) |
| Reactive State | All route data exposed as Signals for reactive updates |
| Navigation Guards | Control navigation flow with sync/async guards |
| Lifecycle Hooks | Hook into navigation events for side effects |
| Lazy Loading | Code-split components with dynamic imports |
| Layout System | Wrap routes with reusable layout components |
| Plugin Architecture | Extend router functionality with plugins |
| Scroll Management | Built-in scroll position tracking |
| Dynamic Routes | Add/remove routes at runtime |
| TypeScript-Friendly | Comprehensive JSDoc type definitions |
eleva)# npm
npm install eleva
# yarn
yarn add eleva
# bun
bun add eleva
// ES Modules (recommended)
import { Eleva } from "eleva";
import { Router } from "eleva/plugins";
// CommonJS
const { Eleva } = require("eleva");
const { Router } = require("eleva/plugins");
<script src="https://cdn.jsdelivr.net/npm/eleva"></script>
<script src="https://cdn.jsdelivr.net/npm/eleva/plugins"></script>
<script>
const { Eleva } = window.Eleva;
const { Router } = window.ElevaPlugins;
</script>
This section walks through creating a basic single-page application with routing.
// File: components/HomePage.js
export const HomePage = {
template: () => `
<div class="page home-page">
<h1>Welcome Home</h1>
<p>This is the home page.</p>
<nav>
<a href="#/about">About</a>
<a href="#/users/123">User 123</a>
</nav>
</div>
`
};
// File: components/AboutPage.js
export const AboutPage = {
template: () => `
<div class="page about-page">
<h1>About Us</h1>
<a href="#/">Back Home</a>
</div>
`
};
// File: components/UserPage.js
export const UserPage = {
setup(ctx) {
// Access route params from context
const userId = ctx.router.currentParams.value.id;
return { userId };
},
template: (ctx) => `
<div class="page user-page">
<h1>User Profile</h1>
<p>Viewing user ID: <strong>${ctx.userId}</strong></p>
<a href="#/">Back Home</a>
</div>
`
};
// File: components/NotFoundPage.js
export const NotFoundPage = {
template: () => `
<div class="page not-found-page">
<h1>404 - Page Not Found</h1>
<a href="#/">Go Home</a>
</div>
`
};
// File: main.js
import { Eleva } from "eleva";
import { Router } from "eleva/plugins";
import { HomePage } from "./components/HomePage.js";
import { AboutPage } from "./components/AboutPage.js";
import { UserPage } from "./components/UserPage.js";
import { NotFoundPage } from "./components/NotFoundPage.js";
// 1. Create Eleva instance
const app = new Eleva("myApp");
// 2. Define routes
const routes = [
{ path: "/", component: HomePage },
{ path: "/about", component: AboutPage },
{ path: "/users/:id", component: UserPage },
{ path: "*", component: NotFoundPage } // Must be last
];
// 3. Install router plugin
const router = app.use(Router, {
mode: "hash", // Use hash-based routing
mount: "#app", // Mount point in HTML
routes: routes
});
// 4. Start the router
router.start().then(() => {
console.log("Router started!");
console.log("Current route:", router.currentRoute.value?.path);
});
<!-- File: index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Eleva App</title>
</head>
<body>
<!-- Router mounts here -->
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>
When you open the app:
/#/ shows HomePage/#/about, shows AboutPage/#/users/123, shows UserPage with ID “123”/#/unknown → shows NotFoundPageconst router = app.use(Router, {
// REQUIRED: Where to mount the router
mount: "#app",
// REQUIRED: Route definitions
routes: [],
// Routing mode (default: "hash")
// - "hash": Uses URL hash (/#/path) - no server config needed
// - "history": Uses clean URLs (/path) - requires server config
// - "query": Uses query params (?view=/path) - for embedded apps
mode: "hash",
// Selector for view container within layout (default: "root")
// The router looks for: #root, .root, [data-root], or "root" element
viewSelector: "root",
// Default layout for all routes (optional)
globalLayout: LayoutComponent,
// Query parameter name for "query" mode (default: "view")
queryParam: "view",
// Global navigation guard (optional)
onBeforeEach: (to, from) => {
// Return true to allow, false to block, or string/object to redirect
}
});
{ mode: "hash" }
// URLs: https://example.com/#/users/123
Pros:
Cons:
# character{ mode: "history" }
// URLs: https://example.com/users/123
Pros:
Cons:
Server Configuration Examples:
# Nginx
location / {
try_files $uri $uri/ /index.html;
}
# Apache (.htaccess)
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
{ mode: "query", queryParam: "view" }
// URLs: https://example.com?view=/users/123
Pros:
Cons:
{
// REQUIRED: URL pattern
path: "/users/:id",
// REQUIRED: Component to render
component: UserPage,
// OPTIONAL: Layout wrapper
layout: MainLayout,
// OPTIONAL: Route name for programmatic navigation
name: "user-profile",
// OPTIONAL: Custom metadata
meta: {
requiresAuth: true,
title: "User Profile",
roles: ["user", "admin"]
},
// OPTIONAL: Guards and hooks
beforeEnter: (to, from) => { /* ... */ },
afterEnter: (to, from) => { /* ... */ },
beforeLeave: (to, from) => { /* ... */ },
afterLeave: (to, from) => { /* ... */ }
}
{ path: "/", component: HomePage } // Matches: /
{ path: "/about", component: AboutPage } // Matches: /about
{ path: "/contact/us", component: Contact } // Matches: /contact/us
// Single parameter
{ path: "/users/:id", component: UserPage }
// Matches: /users/123, /users/abc
// Params: { id: "123" } or { id: "abc" }
// Multiple parameters
{ path: "/posts/:category/:slug", component: PostPage }
// Matches: /posts/tech/hello-world
// Params: { category: "tech", slug: "hello-world" }
// Parameters are always strings
// Access in component:
setup(ctx) {
const id = ctx.router.currentParams.value.id; // "123" (string)
const numId = parseInt(id, 10); // 123 (number)
}
// IMPORTANT: Must be the LAST route in the array
{ path: "*", component: NotFoundPage }
// Matches: /any/unknown/path
// Params: { pathMatch: "any/unknown/path" }
{
path: "/inline",
component: {
template: () => `<h1>Inline Component</h1>`
}
}
// First, register the component
app.component("MyPage", {
template: () => `<h1>My Page</h1>`
});
// Then reference by name
{
path: "/mypage",
component: "MyPage"
}
import { DashboardPage } from "./pages/Dashboard.js";
{
path: "/dashboard",
component: DashboardPage
}
{
path: "/admin",
component: () => import("./pages/AdminPage.js")
// Module must export: export default { ... } or export const AdminPage = { ... }
}
Metadata is arbitrary data attached to routes, accessible in guards and components.
{
path: "/admin/settings",
component: AdminSettings,
meta: {
requiresAuth: true, // Custom: auth required
roles: ["admin"], // Custom: required roles
title: "Admin Settings", // Custom: page title
breadcrumb: "Settings", // Custom: breadcrumb label
transition: "slide-left" // Custom: page transition
}
}
// Access in guards
router.onBeforeEach((to, from) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return "/login";
}
if (to.meta.roles && !hasRoles(to.meta.roles)) {
return "/unauthorized";
}
});
// Access in components
setup(ctx) {
const title = ctx.router.currentRoute.value.meta.title;
document.title = title;
}
// Navigate to path
await router.navigate("/users/123");
// Result: URL changes to /#/users/123
// Navigate with inline params
await router.navigate("/users/:id", { id: "456" });
// Result: URL changes to /#/users/456
await router.navigate({
// Target path (can include :param placeholders)
path: "/users/:id",
// Replace :param placeholders in path
params: { id: "123" },
// Add query string (?key=value)
query: { tab: "profile", sort: "name" },
// Replace current history entry instead of pushing new one
replace: true,
// State object passed to history.pushState/replaceState
state: { scrollPosition: 100, fromDashboard: true }
});
// Result: URL changes to /#/users/123?tab=profile&sort=name
const success = await router.navigate("/protected-page");
if (success) {
console.log("Navigation succeeded");
} else {
console.log("Navigation was blocked by a guard or failed");
}
// Navigating to the current route returns true (no-op)
// Current URL: /#/users/123
const result = await router.navigate("/users/123");
console.log(result); // true (already there, considered successful)
<!-- Direct hash links work automatically -->
<a href="#/">Home</a>
<a href="#/about">About</a>
<a href="#/users/123">User 123</a>
<a href="#/search?q=eleva">Search</a>
const NavComponent = {
setup(ctx) {
const goToUser = async (id) => {
await ctx.router.navigate(`/users/${id}`);
};
const goToSearch = async (query) => {
await ctx.router.navigate({
path: "/search",
query: { q: query }
});
};
return { goToUser, goToSearch };
},
template: (ctx) => `
<nav>
<button @click="goToUser('123')">View User 123</button>
<button @click="goToSearch('eleva')">Search Eleva</button>
</nav>
`
};
| Method | Description | Returns |
|---|---|---|
navigate(path) |
Navigate to path string | Promise<boolean> |
navigate(path, params) |
Navigate with param replacement | Promise<boolean> |
navigate(options) |
Navigate with full options | Promise<boolean> |
Guards are functions that control the navigation flow. They can allow, cancel, or redirect navigation.
When navigating from /a to /b:
router:beforeEach event (can block)router.onBeforeEach()beforeLeave guard on /abeforeEnter guard on /b| Return Value | Type | Effect |
|---|---|---|
true |
boolean |
Allow navigation |
undefined |
void |
Allow navigation (implicit) |
false |
boolean |
Cancel navigation |
"/path" |
string |
Redirect to path |
{ path: "/path", ... } |
object |
Redirect with options |
app.use(Router, {
mount: "#app",
routes: [...],
onBeforeEach: (to, from) => {
console.log(`Navigating: ${from?.path || 'initial'} → ${to.path}`);
// Allow navigation
return true;
}
});
// Guard 1: Logging
const unsubLog = router.onBeforeEach((to, from) => {
console.log(`Navigation: ${from?.path} → ${to.path}`);
// No return = allow
});
// Guard 2: Authentication
const unsubAuth = router.onBeforeEach((to, from) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return "/login"; // Redirect to login
}
});
// Guard 3: Authorization
const unsubRoles = router.onBeforeEach((to, from) => {
if (to.meta.roles && !hasAnyRole(to.meta.roles)) {
return { path: "/error", query: { code: "403" } };
}
});
// Later: Remove specific guards
unsubLog(); // Remove logging guard
unsubAuth(); // Remove auth guard
{
path: "/settings",
component: SettingsPage,
meta: { requiresAuth: true },
// Runs before entering this route
beforeEnter: async (to, from) => {
// Async validation
const canAccess = await checkPermissions();
if (!canAccess) {
return "/unauthorized";
}
// Allow navigation (implicit return)
},
// Runs before leaving this route
beforeLeave: (to, from) => {
if (hasUnsavedChanges()) {
// Browser confirm dialog
const confirmed = confirm("You have unsaved changes. Leave anyway?");
return confirmed; // true = allow, false = cancel
}
}
}
Guards can be async functions for API calls, permission checks, etc.
router.onBeforeEach(async (to, from) => {
if (to.meta.requiresAuth) {
try {
// Validate session with server
const response = await fetch("/api/auth/validate");
const { valid } = await response.json();
if (!valid) {
return {
path: "/login",
query: { redirect: to.path } // Remember where they wanted to go
};
}
} catch (error) {
console.error("Auth check failed:", error);
return "/error";
}
}
});
router.onBeforeEach((to, from) => {
// `to` - Target route location
console.log(to.path); // "/users/123"
console.log(to.params); // { id: "123" }
console.log(to.query); // { tab: "settings" }
console.log(to.meta); // { requiresAuth: true }
console.log(to.fullUrl); // "/users/123?tab=settings"
console.log(to.matched); // Route definition object
// `from` - Source route location (null on initial navigation)
if (from) {
console.log(from.path); // "/dashboard"
}
});
Hooks are functions for side effects. Unlike guards, they cannot block navigation.
| Hook | When Called | Use Cases |
|---|---|---|
onAfterEnter |
After new component mounted | Update title, focus element |
onAfterLeave |
After old component unmounted | Cleanup resources |
onAfterEach |
After navigation completes | Analytics, logging |
onError |
On navigation error | Error reporting |
// Update document title after entering a route
const unsubTitle = router.onAfterEnter((to, from) => {
document.title = to.meta.title || "My App";
});
// Clean up resources after leaving a route
const unsubCleanup = router.onAfterLeave((to, from) => {
// Close any open modals
closeAllModals();
// Abort pending requests for the old route
abortPendingRequests(from?.path);
});
// Track page views
const unsubAnalytics = router.onAfterEach((to, from) => {
analytics.pageView({
path: to.path,
title: to.meta.title,
referrer: from?.path
});
});
// Report navigation errors
const unsubErrors = router.onError((error, to, from) => {
errorReporter.capture(error, {
context: "navigation",
to: to?.path,
from: from?.path
});
});
All hook methods return an unsubscribe function:
// Store unsubscribe functions
const cleanupFns = [];
cleanupFns.push(router.onAfterEach((to) => { /* ... */ }));
cleanupFns.push(router.onError((err) => { /* ... */ }));
// Later, clean up all hooks
function cleanup() {
cleanupFns.forEach(fn => fn());
cleanupFns.length = 0;
}
Layouts wrap route components with shared UI elements (headers, sidebars, footers).
┌─────────────────────────────────┐
│ Layout │
│ ┌───────────────────────────┐ │
│ │ Header │ │
│ ├───────────────────────────┤ │
│ │ ┌───────────────────┐ │ │
│ │ │ Route Component │ │ │ ← viewSelector points here
│ │ │ (Page View) │ │ │
│ │ └───────────────────┘ │ │
│ ├───────────────────────────┤ │
│ │ Footer │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
Applied to all routes unless overridden.
// Define layout component
const MainLayout = {
template: () => `
<div class="main-layout">
<header class="header">
<h1>My App</h1>
<nav>
<a href="#/">Home</a>
<a href="#/about">About</a>
</nav>
</header>
<main id="root">
<!-- Route components render here -->
</main>
<footer class="footer">
© 2024 My App
</footer>
</div>
`
};
// Apply globally
const router = app.use(Router, {
mount: "#app",
globalLayout: MainLayout,
viewSelector: "root", // Matches <main id="root">
routes: [...]
});
Override the global layout for specific routes.
const AdminLayout = {
template: () => `
<div class="admin-layout">
<aside class="sidebar">
<nav>
<a href="#/admin">Dashboard</a>
<a href="#/admin/users">Users</a>
<a href="#/admin/settings">Settings</a>
</nav>
</aside>
<main id="root"></main>
</div>
`
};
const routes = [
// Uses global layout
{ path: "/", component: HomePage },
{ path: "/about", component: AboutPage },
// Uses AdminLayout
{ path: "/admin", component: AdminDashboard, layout: AdminLayout },
{ path: "/admin/users", component: AdminUsers, layout: AdminLayout },
// No layout (null explicitly disables)
{ path: "/login", component: LoginPage, layout: null },
{ path: "/fullscreen", component: FullscreenApp, layout: null }
];
{
path: "/admin",
component: () => import("./pages/Admin.js"),
layout: () => import("./layouts/AdminLayout.js")
}
Reduce initial bundle size by loading route components on-demand.
{
path: "/dashboard",
// Dynamic import - loaded when route is visited
component: () => import("./pages/Dashboard.js")
}
// The module should export:
// export default DashboardComponent
// OR
// export const Dashboard = { ... }
Show feedback while components load:
// Simple loading indicator using events
router.emitter.on("router:beforeResolve", (context) => {
document.getElementById("loading").style.display = "block";
});
router.emitter.on("router:afterResolve", (context) => {
document.getElementById("loading").style.display = "none";
});
<div id="app"></div>
<div id="loading" style="display: none;">Loading...</div>
const LoadingPlugin = {
name: "loading-indicator",
install(router, options = {}) {
const {
delay = 200, // Don't show for fast loads
minDuration = 500, // Minimum display time
element = "#loading"
} = options;
let loadingEl = null;
let showTimeout = null;
let startTime = 0;
router.emitter.on("router:beforeResolve", () => {
loadingEl = document.querySelector(element);
startTime = Date.now();
// Delay showing to avoid flash for fast loads
showTimeout = setTimeout(() => {
if (loadingEl) loadingEl.style.display = "block";
}, delay);
});
router.emitter.on("router:afterResolve", () => {
clearTimeout(showTimeout);
// Ensure minimum display time
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, minDuration - elapsed);
setTimeout(() => {
if (loadingEl) loadingEl.style.display = "none";
}, remaining);
});
}
};
router.use(LoadingPlugin, { delay: 100, minDuration: 300 });
All route data is exposed as Signals - reactive values that trigger updates when changed.
| Signal | Type | Description |
|---|---|---|
currentRoute |
Signal<RouteLocation \| null> |
Complete current route info |
previousRoute |
Signal<RouteLocation \| null> |
Previous route info |
currentParams |
Signal<Record<string, string>> |
URL parameters |
currentQuery |
Signal<Record<string, string>> |
Query string parameters |
currentLayout |
Signal<MountResult \| null> |
Mounted layout instance |
currentView |
Signal<MountResult \| null> |
Mounted view/page instance |
isReady |
Signal<boolean> |
Router ready state |
// Get current value with .value
const route = router.currentRoute.value;
console.log(route.path); // "/users/123"
console.log(route.params); // { id: "123" }
console.log(route.query); // { tab: "settings" }
console.log(route.meta); // { requiresAuth: true }
// Shorthand access
const params = router.currentParams.value; // { id: "123" }
const query = router.currentQuery.value; // { tab: "settings" }
const ready = router.isReady.value; // true
// Watch for route changes
const unsubRoute = router.currentRoute.watch((route) => {
if (route) {
console.log("Route changed to:", route.path);
document.title = route.meta.title || "My App";
}
});
// Watch for param changes (useful for data fetching)
const unsubParams = router.currentParams.watch((params) => {
if (params.id) {
fetchUserData(params.id);
}
});
// Watch for query changes
const unsubQuery = router.currentQuery.watch((query) => {
if (query.search) {
performSearch(query.search);
}
});
// Wait for router to be ready
router.isReady.watch((ready) => {
if (ready) {
console.log("Router initialized, current path:", router.currentRoute.value?.path);
}
});
// Cleanup watchers when done
unsubRoute();
unsubParams();
unsubQuery();
const UserPage = {
setup(ctx) {
// Get initial value
const userId = ctx.router.currentParams.value.id;
const user = ctx.signal(null);
const loading = ctx.signal(true);
// Fetch initial data
fetchUser(userId).then(data => {
user.value = data;
loading.value = false;
});
// Watch for param changes (same component, different user)
ctx.router.currentParams.watch((params) => {
if (params.id !== user.value?.id) {
loading.value = true;
fetchUser(params.id).then(data => {
user.value = data;
loading.value = false;
});
}
});
return { user, loading };
},
template: (ctx) => `
<div class="user-page">
${ctx.loading.value
? '<p>Loading...</p>'
: `<h1>${ctx.user.value.name}</h1>`
}
</div>
`
};
The router emits events throughout the navigation lifecycle. Events enable plugins and extensions.
User triggers navigation
│
▼
┌────────────────────────┐
│ router:beforeEach │ ← Can BLOCK or REDIRECT
│ (guards run here) │
└────────────────────────┘
│ (if allowed)
▼
┌────────────────────────┐
│ router:beforeResolve │ ← Can BLOCK or REDIRECT
│ │ (show loading indicator)
└────────────────────────┘
│
[Load async components]
│
▼
┌────────────────────────┐
│ router:afterResolve │ ← (hide loading indicator)
└────────────────────────┘
│
[Unmount old component]
│
▼
┌────────────────────────┐
│ router:afterLeave │
└────────────────────────┘
│
[Update state]
│
▼
┌────────────────────────┐
│ router:beforeRender │ ← (start transition)
└────────────────────────┘
│
[Render new component]
│
▼
┌────────────────────────┐
│ router:afterRender │ ← (end transition)
└────────────────────────┘
│
▼
┌────────────────────────┐
│ router:scroll │ ← (handle scroll)
└────────────────────────┘
│
▼
┌────────────────────────┐
│ router:afterEnter │
└────────────────────────┘
│
▼
┌────────────────────────┐
│ router:afterEach │ ← (analytics, cleanup)
└────────────────────────┘
| Event | Parameters | Can Block | Description |
|---|---|---|---|
router:ready |
(router) |
No | Router started and ready |
router:beforeEach |
(context) |
Yes | Before guards run |
router:beforeResolve |
(context) |
Yes | Before async component loading |
router:afterResolve |
(context) |
No | After components loaded |
router:afterLeave |
(to, from) |
No | After old component unmounted |
router:beforeRender |
(context) |
No | Before DOM rendering |
router:afterRender |
(context) |
No | After DOM rendering |
router:scroll |
(context) |
No | For scroll behavior |
router:afterEnter |
(to, from) |
No | After new component mounted |
router:afterEach |
(to, from) |
No | Navigation complete |
router:onError |
(error, to, from) |
No | Navigation error |
router:routeAdded |
(route) |
No | Dynamic route added |
router:routeRemoved |
(route) |
No | Dynamic route removed |
Events that can block receive a context object you can modify:
// Block navigation
router.emitter.on("router:beforeEach", (context) => {
// context.to - Target RouteLocation
// context.from - Source RouteLocation (or null)
// context.cancelled - Set to true to cancel
// context.redirectTo - Set to path or object to redirect
if (shouldBlockNavigation(context.to)) {
context.cancelled = true;
return;
}
if (shouldRedirect(context.to)) {
context.redirectTo = "/other-page";
return;
}
});
// Block before component loading
router.emitter.on("router:beforeResolve", (context) => {
// Same context properties as beforeEach, plus:
// context.route - The matched RouteDefinition
if (maintenanceMode) {
context.redirectTo = "/maintenance";
}
});
// Analytics
router.emitter.on("router:afterEach", (to, from) => {
gtag("event", "page_view", {
page_path: to.path,
page_title: to.meta.title
});
});
// Page transitions
router.emitter.on("router:beforeRender", ({ to, from }) => {
const direction = determineDirection(to, from);
document.body.dataset.transition = direction;
});
router.emitter.on("router:afterRender", () => {
requestAnimationFrame(() => {
document.body.dataset.transition = "";
});
});
// Scroll restoration
router.emitter.on("router:scroll", ({ to, from, savedPosition }) => {
if (savedPosition) {
// Back/forward navigation - restore position
window.scrollTo(savedPosition.x, savedPosition.y);
} else if (to.fullUrl.includes("#")) {
// Anchor link - scroll to element
const id = to.fullUrl.split("#")[1];
document.getElementById(id)?.scrollIntoView();
} else {
// New navigation - scroll to top
window.scrollTo(0, 0);
}
});
Add, remove, and query routes at runtime.
// Add a single route
const removeRoute = router.addRoute({
path: "/new-feature",
component: NewFeaturePage,
meta: { addedDynamically: true }
});
// The route is immediately available
await router.navigate("/new-feature");
// Remove it later
removeRoute();
// Remove by path
const wasRemoved = router.removeRoute("/new-feature");
console.log(wasRemoved); // true if found and removed, false otherwise
// Check if route exists
if (router.hasRoute("/admin")) {
console.log("Admin route is registered");
}
// Get all routes
const allRoutes = router.getRoutes();
console.log(allRoutes.map(r => r.path));
// ["/", "/about", "/users/:id", "*"]
// Get specific route
const userRoute = router.getRoute("/users/:id");
console.log(userRoute?.meta); // { requiresAuth: true }
router.emitter.on("router:routeAdded", (route) => {
console.log("Route added:", route.path);
// Update sitemap, navigation menu, etc.
updateNavigationMenu();
});
router.emitter.on("router:routeRemoved", (route) => {
console.log("Route removed:", route.path);
});
// Load micro-frontend module and register its routes
async function loadMicroFrontend(name) {
const module = await import(`./micro-frontends/${name}/index.js`);
const { routes } = module;
// Store remove functions for cleanup
const removeFunctions = routes.map(route =>
router.addRoute({
...route,
path: `/${name}${route.path}`,
meta: { ...route.meta, microFrontend: name }
})
);
return () => removeFunctions.forEach(fn => fn());
}
// Usage
const unloadProducts = await loadMicroFrontend("products");
// Later: unloadProducts();
The router automatically tracks scroll positions and provides hooks for custom scroll behavior.
The router saves { x, y } scroll positions per route path. When navigating back/forward (browser buttons), the saved position is available.
router.emitter.on("router:scroll", ({ to, from, savedPosition }) => {
// savedPosition is available for back/forward navigation (popstate)
// It's null for new navigation (click, router.navigate)
if (savedPosition) {
// Browser back/forward - restore exact position
window.scrollTo({
left: savedPosition.x,
top: savedPosition.y,
behavior: "instant"
});
} else if (to.fullUrl.includes("#")) {
// Hash/anchor navigation
const hash = to.fullUrl.split("#")[1];
const element = document.getElementById(hash);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
} else {
// New page - scroll to top
window.scrollTo({ top: 0, behavior: "smooth" });
}
});
router.emitter.on("router:scroll", () => {
window.scrollTo(0, 0);
});
const preserveScrollRoutes = ["/search", "/feed"];
router.emitter.on("router:scroll", ({ to, from, savedPosition }) => {
// Don't scroll on routes that should preserve position
if (preserveScrollRoutes.includes(to.path)) {
return;
}
// Normal scroll behavior
if (savedPosition) {
window.scrollTo(savedPosition.x, savedPosition.y);
} else {
window.scrollTo(0, 0);
}
});
router.onError((error, to, from) => {
console.error("Navigation error:", error.message);
console.error("Failed navigation:", from?.path, "→", to?.path);
// Handle specific errors
if (error.message.includes("not found")) {
router.navigate("/404");
} else if (error.message.includes("network")) {
router.navigate("/offline");
}
});
Replace the default error handling behavior:
router.setErrorHandler({
// Called for critical errors (throws)
handle(error, context, details = {}) {
console.error(`[Router Error] ${context}:`, error.message, details);
// Send to error tracking service
errorTracker.capture(error, { context, details });
// Re-throw formatted error
throw new Error(`Router error in ${context}: ${error.message}`);
},
// Called for warnings (logs only)
warn(message, details = {}) {
console.warn(`[Router Warning] ${message}`, details);
},
// Called for non-critical errors (logs only)
log(message, error, details = {}) {
console.error(`[Router] ${message}`, error, details);
errorTracker.capture(error, { message, details });
}
});
router.emitter.on("router:onError", (error, to, from) => {
// Log to analytics
analytics.trackError({
type: "navigation_error",
message: error.message,
to: to?.path,
from: from?.path
});
});
Extend router functionality with reusable plugins.
const MyPlugin = {
// REQUIRED: Unique plugin name
name: "my-plugin",
// OPTIONAL: Plugin version
version: "1.0.0",
// REQUIRED: Called when plugin is installed
install(router, options = {}) {
// Access router API
// Register event handlers
// Add custom functionality
},
// OPTIONAL: Called when router is destroyed
destroy(router) {
// Cleanup resources
}
};
// Install with options
router.use(MyPlugin, { option1: "value1" });
// Get all plugins
const plugins = router.getPlugins();
// [{ name: "my-plugin", version: "1.0.0", ... }]
// Get specific plugin
const myPlugin = router.getPlugin("my-plugin");
// Remove plugin
router.removePlugin("my-plugin");
const AnalyticsPlugin = {
name: "analytics",
version: "1.0.0",
install(router, options = {}) {
const { trackingId, debug = false } = options;
// Initialize analytics
if (trackingId) {
initAnalytics(trackingId);
}
// Track page views
router.emitter.on("router:afterEach", (to, from) => {
if (debug) {
console.log("[Analytics] Page view:", to.path);
}
trackPageView({
path: to.path,
title: to.meta.title,
referrer: from?.fullUrl
});
});
// Track errors
router.emitter.on("router:onError", (error, to, from) => {
trackException({
description: error.message,
fatal: false
});
});
}
};
// Usage
router.use(AnalyticsPlugin, {
trackingId: "UA-123456-1",
debug: process.env.NODE_ENV === "development"
});
const PageTitlePlugin = {
name: "page-title",
install(router, options = {}) {
const {
defaultTitle = "My App",
separator = " | ",
suffix = ""
} = options;
router.emitter.on("router:afterEach", (to) => {
const pageTitle = to.meta.title;
if (pageTitle) {
document.title = pageTitle + separator + suffix;
} else {
document.title = defaultTitle;
}
});
}
};
// Usage
router.use(PageTitlePlugin, {
defaultTitle: "My App",
separator: " - ",
suffix: "My App"
});
// Route with meta.title = "Dashboard" → "Dashboard - My App"
// Route without meta.title → "My App"
const AuthPlugin = {
name: "auth",
version: "1.0.0",
install(router, options = {}) {
const {
loginPath = "/login",
unauthorizedPath = "/unauthorized",
isAuthenticated = () => false,
hasRole = () => true,
redirectParam = "redirect"
} = options;
router.emitter.on("router:beforeEach", (context) => {
const { to } = context;
// Check authentication
if (to.meta.requiresAuth && !isAuthenticated()) {
context.redirectTo = {
path: loginPath,
query: { [redirectParam]: to.fullUrl }
};
return;
}
// Check roles
if (to.meta.roles && to.meta.roles.length > 0) {
const hasRequiredRole = to.meta.roles.some(role => hasRole(role));
if (!hasRequiredRole) {
context.redirectTo = unauthorizedPath;
}
}
});
}
};
// Usage
router.use(AuthPlugin, {
loginPath: "/login",
isAuthenticated: () => !!localStorage.getItem("token"),
hasRole: (role) => {
const user = JSON.parse(localStorage.getItem("user") || "{}");
return user.roles?.includes(role);
}
});
const LoadingPlugin = {
name: "loading",
install(router, options = {}) {
const {
showDelay = 200,
hideDelay = 0,
onShow = () => {},
onHide = () => {}
} = options;
let showTimer = null;
let isLoading = false;
router.emitter.on("router:beforeResolve", () => {
clearTimeout(showTimer);
showTimer = setTimeout(() => {
isLoading = true;
onShow();
}, showDelay);
});
router.emitter.on("router:afterRender", () => {
clearTimeout(showTimer);
if (isLoading) {
setTimeout(() => {
isLoading = false;
onHide();
}, hideDelay);
}
});
},
destroy() {
// Cleanup handled by event unsubscription
}
};
// Usage
router.use(LoadingPlugin, {
showDelay: 150,
onShow: () => document.body.classList.add("loading"),
onHide: () => document.body.classList.remove("loading")
});
The Router plugin includes comprehensive JSDoc type definitions for excellent IDE support.
/** @typedef {import('eleva/plugins').RouteDefinition} RouteDefinition */
/** @typedef {import('eleva/plugins').RouteLocation} RouteLocation */
/** @typedef {import('eleva/plugins').NavigationGuard} NavigationGuard */
/** @typedef {import('eleva/plugins').NavigationTarget} NavigationTarget */
/** @typedef {import('eleva/plugins').RouterOptions} RouterOptions */
/** @typedef {import('eleva/plugins').RouterPlugin} RouterPlugin */
// Routing mode
type RouterMode = "hash" | "history" | "query";
// Router configuration
interface RouterOptions {
mode?: RouterMode;
mount: string;
routes: RouteDefinition[];
viewSelector?: string;
globalLayout?: RouteComponent;
queryParam?: string;
onBeforeEach?: NavigationGuard;
}
// Route definition
interface RouteDefinition {
path: string;
component: RouteComponent;
layout?: RouteComponent;
name?: string;
meta?: Record<string, any>;
beforeEnter?: NavigationGuard;
afterEnter?: NavigationHook;
beforeLeave?: NavigationGuard;
afterLeave?: NavigationHook;
}
// Component types
type RouteComponent =
| string // Registered name
| ComponentDefinition // Inline definition
| (() => Promise<{ default: ComponentDefinition }>); // Lazy import
// Current route information
interface RouteLocation {
path: string;
params: Record<string, string>;
query: Record<string, string>;
meta: Record<string, any>;
name?: string;
fullUrl: string;
matched: RouteDefinition;
}
// Navigation guard
type NavigationGuard = (
to: RouteLocation,
from: RouteLocation | null
) => NavigationGuardResult | Promise<NavigationGuardResult>;
type NavigationGuardResult = boolean | string | NavigationTarget | void;
// Navigation target
interface NavigationTarget {
path: string;
params?: Record<string, string>;
query?: Record<string, string>;
replace?: boolean;
state?: Record<string, any>;
}
// Event contexts
interface NavigationContext {
to: RouteLocation;
from: RouteLocation | null;
cancelled: boolean;
redirectTo: string | NavigationTarget | null;
}
interface ScrollContext {
to: RouteLocation;
from: RouteLocation | null;
savedPosition: { x: number; y: number } | null;
}
/** @type {import('eleva/plugins').RouteDefinition[]} */
const routes = [
{
path: "/users/:id",
component: UserPage,
meta: { requiresAuth: true, title: "User Profile" }
}
];
/** @type {import('eleva/plugins').NavigationGuard} */
const authGuard = (to, from) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
return { path: "/login", query: { redirect: to.path } };
}
};
/** @type {import('eleva/plugins').RouterPlugin} */
const myPlugin = {
name: "my-plugin",
install(router) {
router.onAfterEach((to, from) => {
console.log(to.path);
});
}
};
| Property | Type | Description |
|---|---|---|
eleva |
Eleva |
Parent Eleva instance |
options |
RouterOptions |
Merged configuration options |
routes |
RouteDefinition[] |
Registered routes array |
emitter |
Emitter |
Event emitter instance |
isStarted |
boolean |
Whether router is started |
currentRoute |
Signal<RouteLocation \| null> |
Current route (reactive) |
previousRoute |
Signal<RouteLocation \| null> |
Previous route (reactive) |
currentParams |
Signal<Record<string, string>> |
Route params (reactive) |
currentQuery |
Signal<Record<string, string>> |
Query params (reactive) |
currentLayout |
Signal<MountResult \| null> |
Layout instance (reactive) |
currentView |
Signal<MountResult \| null> |
View instance (reactive) |
isReady |
Signal<boolean> |
Ready state (reactive) |
| Method | Signature | Description |
|---|---|---|
start |
() => Promise<Router> |
Start the router |
stop |
() => Promise<void> |
Stop the router |
destroy |
() => Promise<void> |
Stop and cleanup (alias: stop) |
navigate |
(location, params?) => Promise<boolean> |
Navigate to route |
onBeforeEach |
(guard) => () => void |
Register global guard |
onAfterEnter |
(hook) => () => void |
Register after-enter hook |
onAfterLeave |
(hook) => () => void |
Register after-leave hook |
onAfterEach |
(hook) => () => void |
Register after-each hook |
onError |
(handler) => () => void |
Register error handler |
addRoute |
(route) => () => void |
Add route dynamically |
removeRoute |
(path) => boolean |
Remove route by path |
hasRoute |
(path) => boolean |
Check if route exists |
getRoutes |
() => RouteDefinition[] |
Get all routes |
getRoute |
(path) => RouteDefinition \| undefined |
Get route by path |
use |
(plugin, options?) => void |
Install plugin |
getPlugins |
() => RouterPlugin[] |
Get all plugins |
getPlugin |
(name) => RouterPlugin \| undefined |
Get plugin by name |
removePlugin |
(name) => boolean |
Remove plugin |
setErrorHandler |
(handler) => void |
Set error handler |
// File: main.js
import { Eleva } from "eleva";
import { Router } from "eleva/plugins";
// ============ Components ============
const HomePage = {
template: () => `
<div class="home">
<h1>Welcome</h1>
<nav>
<a href="#/about">About</a>
<a href="#/dashboard">Dashboard</a>
</nav>
</div>
`
};
const AboutPage = {
template: () => `
<div class="about">
<h1>About Us</h1>
<a href="#/">Home</a>
</div>
`
};
const DashboardPage = {
setup(ctx) {
const user = ctx.signal(null);
// Simulate fetching user
setTimeout(() => {
user.value = { name: "John Doe", email: "john@example.com" };
}, 500);
return { user };
},
template: (ctx) => `
<div class="dashboard">
<h1>Dashboard</h1>
${ctx.user.value
? `<p>Welcome, ${ctx.user.value.name}!</p>`
: '<p>Loading...</p>'
}
</div>
`
};
const LoginPage = {
setup(ctx) {
const login = () => {
localStorage.setItem("token", "demo-token");
const redirect = ctx.router.currentQuery.value.redirect || "/dashboard";
ctx.router.navigate(redirect);
};
return { login };
},
template: (ctx) => `
<div class="login">
<h1>Login</h1>
<button @click="login">Login</button>
</div>
`
};
const NotFoundPage = {
template: (ctx) => `
<div class="not-found">
<h1>404 - Not Found</h1>
<p>Path: ${ctx.router.currentParams.value.pathMatch}</p>
<a href="#/">Go Home</a>
</div>
`
};
// ============ Router Setup ============
const app = new Eleva("myApp");
const isAuthenticated = () => !!localStorage.getItem("token");
const router = app.use(Router, {
mode: "hash",
mount: "#app",
routes: [
{ path: "/", component: HomePage },
{ path: "/about", component: AboutPage },
{ path: "/login", component: LoginPage },
{
path: "/dashboard",
component: DashboardPage,
meta: { requiresAuth: true, title: "Dashboard" }
},
{ path: "*", component: NotFoundPage }
],
onBeforeEach: (to, from) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
return { path: "/login", query: { redirect: to.path } };
}
}
});
// Update page title
router.onAfterEach((to) => {
document.title = to.meta.title || "My App";
});
// Start router
router.start().then(() => {
console.log("App ready!");
});
| Vue Router | Eleva Router | Notes |
|---|---|---|
new VueRouter({ routes }) |
app.use(Router, { routes }) |
Plugin-based |
router.push('/path') |
router.navigate('/path') |
Returns Promise |
router.replace('/path') |
router.navigate({ path, replace: true }) |
Via options |
router.go(-1) |
history.back() |
Use native History API |
router.beforeEach(guard) |
router.onBeforeEach(guard) |
Returns unsubscribe |
router.afterEach(hook) |
router.onAfterEach(hook) |
Returns unsubscribe |
$route.params |
router.currentParams.value |
Signal-based |
$route.query |
router.currentQuery.value |
Signal-based |
$route.meta |
router.currentRoute.value.meta |
Via currentRoute |
<router-link to="/path"> |
<a href="#/path"> |
Native links (hash) |
<router-view> |
viewSelector option |
Configure in options |
| React Router | Eleva Router | Notes |
|---|---|---|
<BrowserRouter> |
mode: "history" |
Via options |
<HashRouter> |
mode: "hash" |
Default mode |
useNavigate() |
ctx.router.navigate() |
From context |
useParams() |
router.currentParams.value |
Signal-based |
useLocation() |
router.currentRoute.value |
Full location |
useSearchParams() |
router.currentQuery.value |
Signal-based |
<Route element={<Page />}> |
{ path, component: Page } |
Route definition |
<Outlet> |
viewSelector option |
Layout slot |
loader function |
beforeEnter guard |
Route-level |
errorElement |
onError hook |
Global |
[ElevaRouter] Mount element "#app" was not found in the DOM.
Solution: Ensure the mount element exists before calling router.start():
// Wait for DOM
document.addEventListener("DOMContentLoaded", async () => {
await router.start();
});
Error: Component "MyPage" not registered.
Solution: Register components before defining routes:
app.component("MyPage", MyPageDefinition);
// Then define routes
Solution: Configure server fallback (see History Mode).
Solution: Ensure guard returns a value:
// Wrong - no return
router.onBeforeEach((to) => {
if (!isAuth) "/login"; // Missing return!
});
// Correct
router.onBeforeEach((to) => {
if (!isAuth) return "/login";
});
Correct behavior. URL parameters are always strings. Convert as needed:
const id = parseInt(router.currentParams.value.id, 10);
// Log all navigation events
const events = [
"router:beforeEach", "router:beforeResolve", "router:afterResolve",
"router:beforeRender", "router:afterRender", "router:scroll",
"router:afterEnter", "router:afterLeave", "router:afterEach"
];
events.forEach(event => {
router.emitter.on(event, (...args) => {
console.log(`[${event}]`, ...args);
});
});
The Eleva Router Plugin provides:
For questions or issues, visit the GitHub repository.