Defining Routes
Routes are the map of your application — they connect URLs to views and lifecycle hooks. This page covers every option available on a route definition.
The route definition object
Each route is a plain JavaScript object passed to Router.define():
Router.define([
{
path: '/user/:id',
view: '#user-template',
title: 'User Profile',
onEnter: (params, query, onCleanup) => { /* ... */ },
onLeave: () => { /* ... */ }
}
]);| Property | Type | Required | Description |
|---|---|---|---|
path | string | yes | URL path pattern |
view | string or Function | yes | Template selector or factory function |
title | string | no | Sets document.title on navigation |
onEnter | Function | no | Called after the view mounts |
onLeave | Function | no | Called before the view unmounts |
path
The path is matched against the current URL. Three forms are supported:
Static paths
Exact string match:
{ path: '/', view: '#home' }
{ path: '/about', view: '#about' }
{ path: '/contact', view: '#contact' }Named parameters
Segments prefixed with : become named parameters:
{ path: '/user/:id', view: '#user' }
{ path: '/post/:year/:month/:slug', view: '#post' }
{ path: '/category/:name/page/:num', view: '#category' }The extracted values are available in onEnter as the params object:
{
path: '/user/:id',
view: '#user-template',
onEnter: (params) => {
console.log(params.id); // e.g. '42' for /user/42
}
}Parameter values are always strings — convert them if you need a number:
onEnter: (params) => {
const userId = Number(params.id);
}Catch-all wildcard
'*' matches any URL that no other route matched. Use it for 404 pages:
{ path: '*', view: '#404-template', title: 'Not Found' }Always define the catch-all as the last route. Routes are evaluated in order — regular routes first, catch-all last.
view
The view property tells the router what to render in the outlet. It accepts two forms:
CSS selector (string)
Points to a <template> element in your document:
{ path: '/', view: '#home-template' }<template id="home-template">
<section class="home">
<h1>Welcome</h1>
<p>This is the home page.</p>
</section>
</template>The router clones the template's content and appends it to the outlet. The original <template> is never modified — each navigation gets a fresh clone.
Factory function
For dynamic views, pass a function that returns an HTMLElement or an HTML string:
{
path: '/user/:id',
view: async (params, query) => {
const user = await fetchUser(params.id);
return `
<div class="profile">
<h1>${user.name}</h1>
<p>${user.bio}</p>
</div>
`;
}
}The factory receives the same (params, query) arguments as onEnter. It may be async.
Return values:
- An HTML string → wrapped in a
<div>and appended to the outlet - An HTMLElement → appended directly
// Returning an element
{
path: '/counter',
view: () => {
const el = document.createElement('div');
el.className = 'counter';
el.textContent = 'Count: 0';
return el;
}
}title
Sets document.title when this route becomes active:
{ path: '/about', view: '#about', title: 'About Us' }For dynamic titles based on params, use Router.setTitleResolver() from the guards module instead.
onEnter
Called after the view has been mounted into the outlet:
{
path: '/dashboard',
view: '#dashboard-template',
onEnter: (params, query, onCleanup) => {
// params — named route parameters
// query — URLSearchParams for the current URL
// onCleanup — register teardown functions
}
}Parameters
params — an object with the extracted named parameters:
// Route: /post/:year/:slug
// URL: /post/2024/hello-world
onEnter: (params) => {
console.log(params.year); // '2024'
console.log(params.slug); // 'hello-world'
}query — a URLSearchParams instance for query string values:
// URL: /search?q=router&page=2
onEnter: (params, query) => {
console.log(query.get('q')); // 'router'
console.log(query.get('page')); // '2'
}onCleanup — register functions to run when the view is unmounted:
onEnter: (params, query, onCleanup) => {
const interval = setInterval(updateClock, 1000);
// This runs automatically when leaving the route
onCleanup(() => clearInterval(interval));
}Async onEnter
onEnter can be async. The router awaits it before emitting the change event:
{
path: '/profile',
view: '#profile-template',
onEnter: async (params, query, onCleanup) => {
const data = await fetchProfile();
document.getElementById('username').textContent = data.name;
}
}onLeave
Called before the current view is unmounted. Useful for saving state or showing a confirmation:
{
path: '/editor',
view: '#editor-template',
onLeave: () => {
saveDraft();
}
}onLeave can also be async:
onLeave: async () => {
await saveToServer();
}Note: onLeave runs before onCleanup functions. The sequence on navigation away is:
onLeave()on the current routeonCleanup()functions registered duringonEnter- Outlet cleared
- New view mounted
onEnter()on the new route
Route order matters
Routes are evaluated top to bottom. More specific routes should come before less specific ones:
Router.define([
{ path: '/', view: '#home' }, // exact match first
{ path: '/user/new', view: '#new-user' }, // specific before param
{ path: '/user/:id', view: '#user' }, // param route second
{ path: '*', view: '#404' }, // catch-all always last
]);If you put /user/:id before /user/new, navigating to /user/new would match :id with the value 'new' instead of hitting the intended route.
Complete route examples
Static pages
Router.define([
{ path: '/', view: '#home', title: 'Home' },
{ path: '/about', view: '#about', title: 'About' },
{ path: '/contact', view: '#contact', title: 'Contact Us' },
{ path: '*', view: '#404', title: 'Not Found' },
]);Blog with dynamic posts
Router.define([
{
path: '/',
view: '#home-template',
title: 'Blog Home'
},
{
path: '/post/:slug',
view: '#post-template',
title: 'Post',
onEnter: async (params, query, onCleanup) => {
const post = await fetchPost(params.slug);
document.getElementById('post-title').textContent = post.title;
document.getElementById('post-body').innerHTML = post.html;
document.title = post.title + ' — My Blog';
}
},
{
path: '*',
view: '#404-template',
title: 'Not Found'
}
]);Dashboard with data fetching and cleanup
Router.define([
{
path: '/dashboard',
view: '#dashboard-template',
onEnter: (params, query, onCleanup) => {
// Poll for live stats
const timer = setInterval(async () => {
const stats = await fetchStats();
renderStats(stats);
}, 5000);
// Auto-cancelled when leaving the dashboard
onCleanup(() => clearInterval(timer));
},
onLeave: () => {
console.log('Left dashboard');
}
}
]);Factory view with dynamic content
Router.define([
{
path: '/user/:id',
view: async (params) => {
const user = await fetchUser(params.id);
return `
<div class="user-card">
<img src="${user.avatar}" alt="${user.name}">
<h1>${user.name}</h1>
<p>${user.bio}</p>
</div>
`;
},
title: 'User Profile'
}
]);Key takeaways
pathsupports static strings,:namedparams, and*wildcardviewis a<template>selector or a factory function returning HTML/elementonEnterreceives(params, query, onCleanup)and can be asynconLeaveruns before unmount and can be asynconCleanup— register teardown insideonEnterto keep cleanup colocated- Route order matters — put specific routes before generic ones, catch-all last
What's next?
Now that you know how to define routes, let's look at how to navigate between them:
- Programmatic navigation with
Router.go(),back(), andforward() - Declarative navigation with
[data-route]attributes - Reading the current route with
Router.current()