Routing
Routing in rooted has three pieces:
routedeclares a URL pattern bound to a component resolver.gateshows or hides content depending on whether a URL matches.routerpicks the best-matching route and renders it.
All three live in @rooted/router.
Declaring a route
route is a tagged-template function. The pattern goes in the template string. Typed parameters go in interpolations.
import { route, token } from '@rooted/router/routes'
export const ArticleRoute = route`/articles/${token('id', Number)}/`({
resolve: ({ create, tokens }) => create(Article, {
id: tokens.id
}),
})Patterns must start and end with a /. The route is just a value, so you can import it from anywhere to build a link.
resolve returns the element to render. It receives create (the component factory) and tokens (the parsed parameters, typed). It can be async, which is how you lazy-load route components.
async resolve({ create, tokens }) {
const { Article } = await import('./article.mts')
return create(Article, {
id: tokens.id
})
}Token types
token('id', Number) parses the segment as a number. The token type flows through to tokens.id (which is typed number).
Built-ins: String, Number, Boolean, BigInt, Date. Pass any of them as the second argument to token().
Constant values
Pass an array instead of a constructor to only match the listed values. The matched value keeps its literal type.
export const PlanRoute = route`/plans/${token('plan', ['free', 'pro', 'team'])}/`({
resolve: ({ create, tokens }) => create(PlanPage, {
plan: tokens.plan
}),
})
// tokens.plan is 'free' | 'pro' | 'team'; /plans/enterprise/ does not matchThe array must hold values of one kind, all strings or all numbers. Because the possible values are known, routes whose only dynamic parts are constant tokens are unrolled to concrete paths at build time: each value gets its own prerendered page and sitemap entry. Routes that mix a constant token with a typed token or wildcard stay dynamic.
Wildcard
Use wildcard() as the last interpolation to match the rest of the path. Its key is rest by default.
import { route, wildcard } from '@rooted/router/routes'
export const ArchiveRoute = route`/archive/${wildcard()}/`({
resolve: ({ create, tokens }) => create(Archive, {
slug: tokens.rest
}),
})Wildcards must come last, and you can have at most one per pattern.
Child routes
To compose a child URL onto a parent route, interpolate the parent route as the first value:
import { route, token } from '@rooted/router/routes'
import { ArticleRoute } from '../articles/_routes.mts'
export const CommentsRoute = route`/${ArticleRoute}/comments/`({
resolve: ({ create, tokens }) => create(Comments, {
articleId: tokens.id
}),
})The child route inherits the parent's tokens (tokens.id is in scope without re-declaring it). Only the first interpolation can be a parent route.
Returning undefined
Returning undefined from resolve means "this URL does not match this route after all". The router treats it as a non-match and falls through to the notFound component. It also blocks shorter parent routes from catching the URL by accident.
This is how you gate dynamic routes on real data:
async resolve({ create, tokens }) {
const found = await loadCategory(tokens.slug)
if (!found) return // 404, instead of rendering with broken data
return create(Category, {
slug: tokens.slug
})
}Per-route SEO
Pass a seo field next to resolve:
export const SearchRoute = route`/search/`({
resolve: ({ create }) => create(Search),
seo: {
title: 'Search recipes',
description: 'Find recipes by keyword, category, or ingredient.',
},
})See SEO for the full set of fields and what happens at build time vs. runtime.
The router
router takes a config object with home, notFound, and any number of named routes. The names are used only for duplicate detection in development.
import { router } from '@rooted/router/application'
import { appRoutes } from './_routes.g.mts'
import { Home } from './home.mts'
import { NotFound } from './not-found.mts'
const Router = router({
home: Home,
notFound: NotFound,
...appRoutes,
})Mount it like any other component:
append(Router)How routes are picked
On every navigation the router evaluates every registered route in parallel. The route whose pattern consumes the most characters wins. If two routes match the same length, the one without a wildcard wins.
A resolve that returns undefined blocks shorter parent routes from catching the URL on the way down (suppression). This is what makes the "return undefined for 404" pattern safe.
Resolved routes are cached by pathname, so the resolver runs at most once per unique URL.
Router options
Pass options as the second argument to create(Router, ...):
create(Router, {
viewTransition: true,
scrollBehavior: { scrollToTop: 'on:end' },
on: {
navigate(event) {
if (event.navigationType === 'start') showProgressBar()
if (event.navigationType === 'end') hideProgressBar()
},
},
})| Option | What it does |
|---|---|
viewTransition | Wrap renders in document.startViewTransition when available. |
scrollBehavior.scrollToTop | When to scroll to top: on:start, on:end, on:start-and-end (default), or skip. |
scrollBehavior.saveScrollBeforeNavigate | Save the scroll position onto the entry a navigation leaves, so back restores it. Default true. |
scrollBehavior.target | A custom scroll container. Defaults to window. |
on.navigate | Fires twice per navigation, with event.navigationType === 'start' and 'end'. |
on.error | Fires when a route's resolve throws. See When a route fails. |
seo | Runtime SEO meta options. See SEO. |
When a route fails
If a route's resolve throws, the router renders notFound, the same as a URL nothing matches. In practice this is usually a lazy import() whose chunk couldn't be fetched, a stale chunk after a deploy for example.
The error isn't swallowed. It goes to on.error as a NavigationErrorEvent, with the error in event.detail, the failing route and the href. Unless the handler sets event.errorHandled = true, the router then passes it to reportError, so it shows up on window's error event and in on('global', 'unhandled-error') like any uncaught exception.
create(Router, {
on: {
error(event) {
// A reload picks up the new chunks after a deploy. Only once per page, or a chunk that's really gone reloads forever.
if (!event.detail.message.includes('dynamically imported module')) return
if (sessionStorage.getItem('reloaded-for') === event.href) return
sessionStorage.setItem('reloaded-for', event.href)
event.errorHandled = true
location.reload()
},
},
})The message check is only an illustration. The exact text differs per browser, so match whatever your bundler and target browsers produce.
A failing route competes on specificity like any other route. A broader route, a wildcard fallback for example, doesn't hide a more specific route that threw.
Navigation
Two ways to navigate.
Link
Link is a component. It renders an <a> and intercepts clicks for in-app navigation.
import { Link } from '@rooted/router'
create(Link, {
href: '/about/',
children: 'About'
})If target is set (other than _self), the click is not intercepted and the browser handles the link.
To build a typed href from a route reference, use href.for:
import { href, Link } from '@rooted/router'
import { ArticleRoute } from '../articles/_routes.mts'
create(Link, {
href: href.for(ArticleRoute, { id: 42 }),
children: 'Read article',
})href.for requires every parameter the route declares (and any inherited from a parent). TypeScript complains if you forget one.
navigate
navigate is the imperative version. Call it from anywhere.
import { navigate } from '@rooted/router'
navigate('/about/')
navigate(href.for(ArticleRoute, {
id: 42
}))There is also a state-only overload that pushes history state without changing the URL. Useful for modal state that should be back-button- aware:
navigate({
modal: 'confirm'
})Calling navigate adds a history entry. navigate.replace overwrites the current one instead, and takes the same arguments:
navigate.replace('/en/')
navigate.replace({
modal: 'confirm'
})That's what a redirect wants. A landing page that sends visitors to their remembered locale should not leave itself in history, or Back returns to it and immediately redirects forward again:
navigate.replace(href.for(HomeRoute, {
locale: remembered
}))restoreScrollPosition
A push saves the scroll position onto the entry it leaves, and the router scrolls back to it when you return. It only does that for navigations it considers a route change, so a back that changes nothing but the query string or the hash leaves the page where it is. Call restoreScrollPosition when you want that one back:
import { href, restoreScrollPosition } from '@rooted/router'
on('window', 'popstate', () => {
if (href.current().query.has('page')) restoreScrollPosition()
})It takes no arguments: every mounted router is scrolled back to the position saved for it, in whatever container that router was given. It returns false when nothing was saved, which includes the case where scroll saving is switched off.
Gates
A gate is a self-managing component that mounts and unmounts a piece of UI based on whether a route matches. Gates run independently of the router. They are the right tool when one shell needs to show different sub-content at different URLs.
import { gate, route, token } from '@rooted/router'
export const ArticleRoute = route`/articles/${token('id', Number)}/`({
resolve: ({ create }) => create(ArticleShell),
})
const ArticleGate = gate(ArticleRoute, ({ id }) => create(ArticleContent, {
id
}))
// Inside ArticleShell:
append(ArticleGate)The gate calls its render function when the route matches and removes the rendered content when it stops matching. If the route stays matched but the tokens change (different article id), the content is re-rendered with the new tokens.