Routing
@stewie-js/router provides reactive client-side and SSR routing. The current location is a store() — components subscribe only to the specific URL properties they read, so a query string change does not trigger components that only care about the pathname.
Installation
pnpm add @stewie-js/routerBasic setup
Wrap your app in <Router> and define routes with <Route>:
import { Router, Route } from '@stewie-js/router'
function App() {
return (
<Router>
<Route path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/users/:id" component={UserDetail} />
</Router>
)
}<Router> renders the component matched to the current URL and reacts to navigation automatically. In browsers with the Navigation API it intercepts all navigations; otherwise it listens to popstate.
Route changes use the View Transitions API when available, giving you smooth animated transitions with zero configuration.
Typed routes with createRoute
<Route> works, but it splits a route across two declarations: the JSX (path, component, load) and — if you want typed params — a separate hand-written type passed to useParams<T>(). Rename a path segment and the type silently drifts out of sync.
createRoute(path, config) collapses both into one value. The path, the runtime config (component, guard, loader), and the param/query types live in a single declaration:
import { createRoute } from '@stewie-js/router'
// P is inferred from the path literal — { projectId: string }
export const ProjectEditRoute = createRoute(
'/projects/:projectId/edit',
{ component: EditProjectPage, load: projectEditLoader }
)
// No params, but a typed query — pass explicit generics
export const LoginRoute = createRoute<{}, { redirect?: string }>(
'/login',
{ component: LoginPage }
)The returned value is the route component — render it directly inside <Router>:
<Router>
<ProjectEditRoute />
<LoginRoute />
</Router>And it carries its own types, so useParams / useQuery become value-typed with no annotation — pass the route itself:
function EditProjectPage() {
const { projectId } = useParams(ProjectEditRoute) // string, no generic needed
const { redirect } = useQuery(LoginRoute) // string | undefined
}Layout routes work the same way — children are declared at the JSX usage site, not in the config:
export const AppShellRoute = createRoute('/', { component: AppShellLayout })
<Router>
<AppShellRoute>
<DashboardRoute />
<ProjectEditRoute />
</AppShellRoute>
</Router>createRoute is the recommended way to declare routes. Raw <Route> remains the underlying primitive — it still works, and you can mix both shapes in the same tree (the <Router> child-walker recognises each). Reach for raw <Route> when you don't need typed params; reach for createRoute the moment a route has :params or a query shape worth typing.
Links
Use <Link> for client-side navigation. It renders an <a> tag but intercepts clicks to avoid full-page reloads.
import { Link } from '@stewie-js/router'
<Link to="/about">About</Link>
<Link to="/dashboard" replace>Dashboard</Link>Modifier key clicks (Ctrl, Cmd, Alt, Shift) pass through to the browser so users can open links in new tabs.
Programmatic navigation
import { useRouter } from '@stewie-js/router'
function LogoutButton() {
const router = useRouter()
return (
<button onClick={() => router.navigate('/login')}>
Log out
</button>
)
}navigate accepts a string URL or an options object:
router.navigate('/dashboard')
router.navigate({ to: '/login', replace: true })
router.back()
router.forward()Route parameters
Access the current route's parameters with useParams. Parameters are the :name segments in the route path.
If the route was declared with createRoute, pass the route for value-typed params with no annotation:
import { useParams } from '@stewie-js/router'
function UserDetail() {
const { id } = useParams(UserRoute) // typed from the route's path literal
return <p>User: {id}</p>
}For raw <Route> definitions, annotate the shape instead:
const { id } = useParams<{ id: string }>()Params are fixed for the lifetime of the component, so destructuring them is safe. They are derived from the pathname, and a pathname change re-mounts the route component — navigating /users/1 → /users/2 gives you a newUserDetail instance with id of '2', rather than mutating the old one. That is why there is nothing to subscribe to.
This is the opposite of useQuery, which is reactive: a setQuery call deliberately does not re-mount the route, so a mounted component has to observe query changes in place.
Query string
Read query values with useQuery — value-typed from a createRoute route, or annotated for raw routes:
import { useQuery } from '@stewie-js/router'
function SearchPage() {
const { q, page } = useQuery(SearchRoute) // or useQuery<{ q: string; page: string }>()
return <p>Searching for: {q}</p>
}Because location is a store, a component reading only query.q is not notified when query.page changes.
Updating the query without re-running the route
For filters and live search, you want the URL and useQuery() to update on every keystroke without re-running guards, re-running loaders, or remounting the route. Calling navigate() for that would tear down and rebuild the route subtree — losing input focus. Use setQuery instead:
function SearchBox() {
const router = useRouter()
const q = useQuery(SearchRoute).q
return (
<input
value={q}
onInput={e => router.setQuery({ q: e.currentTarget.value })}
/>
)
}setQuery(patch, options?) is a synchronous URL + location.query annotation:
- The URL and the reactive
location.queryupdate immediately —useQuery()consumers see the new value on the same tick. - No guards run. No loaders run. The route never remounts.
nullorundefinedin the patch deletes that key.- Default history method is
replaceState; pass{ push: true }to add a back-button entry (e.g. a multi-step filter flow).
Query-reactive data belongs at the fetch site, not the URL site: declare it with useResource(fn, () => location.query.someKey) so the dependency lives where the fetch is, deduplicates by registry key, and stays out of the routing lifecycle. Loaders are for cross-boundary navigation where guards also need to run — a filter or search box does not cross that boundary.
Footgun (STW075): if a route's
load(params, query)reads itsqueryargument and you mutate that key withsetQuery,useRouteData()will hold stale data until the next realnavigate(). Move the query-dependent fetch into auseResourceat the consumer.
Route guards
A guard runs before a route activates. Return true to allow navigation or a URL string to redirect.
import type { RouteGuard } from '@stewie-js/router'
const requireAuth: RouteGuard = async (to, from) => {
const ok = await checkSession()
return ok ? true : `/login?next=${encodeURIComponent(to)}`
}Attach the guard to a route:
<Route path="/dashboard" component={Dashboard} beforeEnter={requireAuth} />Guards also run on browser back/forward navigation, not just programmatic navigate() calls.
Route-level data loading
The load function on a <Route> runs before the component renders. Use it to fetch data that the component needs before showing anything.
async function loadUser() {
const res = await fetch('/api/me')
return res.json()
}
<Route path="/profile" component={Profile} load={loadUser} />Read the result in the component with useRouteData:
import { useRouteData } from '@stewie-js/router'
function Profile() {
const user = useRouteData<User>()
return <h1>Hello, {user.name}</h1>
}useRouteData() is reactive — it updates when navigation loads new data.
Lazy routes
Code-split a route component with lazy:
import { lazy } from '@stewie-js/core'
const Settings = lazy(() => import('./pages/Settings'))<Route path="/settings" component={Settings} />The module is fetched on first navigation to the route. The router shows nothing (or the <Router fallback> if provided) while loading.
Server-side rendering
For SSR, pass the request URL to <Router> as initialUrl:
// server entry
const { html } = await renderToString(
<App initialUrl={req.url} />
)function App({ initialUrl }: { initialUrl?: string }) {
return (
<Router initialUrl={initialUrl}>
<Route path="/" component={Home} />
...
</Router>
)
}On the client, <Router> reads window.location by default so you don't need to pass initialUrl.
View Transitions and scroll
Every navigation that commits a new URL runs inside a document.startViewTransition() call (where supported), letting you animate the route swap with CSS. The router also takes responsibility for scroll restoration — you don't need to scroll-to-top in onClick handlers or hand-roll a back/forward scroll cache.
What the router writes to NavigationStatus
useNavigationStatus() (or useRouter().status) exposes:
| Field | Values | Source |
|---|---|---|
kind | 'push' | 'replace' | 'traverse' | 'reload' | The Navigation API spec value for what happened to the URL. traverse means back/forward button, programmatic history.back/forward, or navigation.traverseTo(). |
routeDirection | 'forward' | 'back' | 'default' | 'same' | Computed from the route tree by comparing source and destination chains. See below. |
kind is mechanical — what the browser/history did. routeDirection is structural — where the navigation went in the route tree. They're orthogonal.
routeDirection is structural, not perceptual
routeDirection answers "how did we move through the route tree?", not "did the user perceive forward motion?". This is deliberate.
| Navigation | Direction | Why |
|---|---|---|
/settings → /settings/account | forward | Destination chain extends the source chain (going deeper). |
/settings/account → /settings | back | Source chain extends the destination chain (going up). |
/home → /profile | default | Sibling subtrees; neither chain is a prefix of the other. |
/settings/account → /settings/billing | default | Sibling routes under a shared layout — neither extends the other. |
/products/12345 → /products/98765 | same | Same route pattern; only params changed. |
/search?q=a → /search?q=b | same | Same route; only query changed. |
Heads up.
/products/12345 → /products/98765via a "next product" button issame, notforward. The user perceives forward motion, but the route tree didn't move. If you want a slide animation for paginated detail pages, targetstewie-kind-pushin CSS or animate at the component level inside the route — the router won't infer perceptual direction for you.
Animating with CSS
Inside startViewTransition, the router passes a types[] array so you can scope CSS rules. Every navigation emits:
stewie-kind-{push|replace|traverse|reload}— always.stewie-direction-{forward|back|default|same}— always.stewie-transition-{groupName}— conditional; see transition groups below.
CSS pattern for direction-aware animation:
/* Default for any navigation: a quick fade. */
::view-transition-old(root),
::view-transition-new(root) {
animation: stewie-fade 200ms;
}
/* Same-route (params/query change) — kill the animation entirely. */
:active-view-transition-type(stewie-direction-same) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
@keyframes stewie-fade {
from { opacity: 0; }
to { opacity: 1; }
}Transition groups
Use transition on a layout route to scope a directional animation (e.g. a slide) to navigations that cross into or out of that layout:
const SettingsLayoutRoute = createRoute('/settings', {
component: SettingsShell,
transition: 'slide'
});The router emits stewie-transition-{name} only when:
- Both the source and destination chains include a level with that transition name, AND
routeDirectionisforwardorback.
Sibling tabs under the same layout (/settings/account → /settings/billing) have direction default and so do not trigger the slide. Param-only changes inside the layout have direction same. This is by design: the slide tracks structural movement through the tree.
Cookbook — slides inside a settings shell, fades everywhere else:
/* Slide forward when entering deeper into the settings tree. */
:active-view-transition-type(stewie-transition-slide):active-view-transition-type(stewie-direction-forward) {
::view-transition-old(root) { animation: slide-out-left 280ms ease both; }
::view-transition-new(root) { animation: slide-in-right 280ms ease both; }
}
/* Slide back when leaving the settings tree. */
:active-view-transition-type(stewie-transition-slide):active-view-transition-type(stewie-direction-back) {
::view-transition-old(root) { animation: slide-out-right 280ms ease both; }
::view-transition-new(root) { animation: slide-in-left 280ms ease both; }
}
@keyframes slide-in-right { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes slide-out-left { from { transform: translateX(0); } to { transform: translateX(-100%); } }
@keyframes slide-in-left { from { transform: translateX(-100%); } to { transform: translateX(0); } }
@keyframes slide-out-right { from { transform: translateX(0); } to { transform: translateX(100%); } }/settings → /settings/account slides forward. /settings/account → /settings slides back. /settings/account → /settings/billing falls through to the global fade. /home → /profile falls through to the global fade. No per-link config required.
view-transition-name is the author's responsibility
The router does not auto-scope view-transition-name. If you give two elements the same name on the same page, the View Transition will error and skip. When using named transitions for shared-element animation (e.g. a thumbnail morphing into a detail-page hero), generate unique names per element instance:
<img view-transition-name={`product-${product.id}`} src={...} />Scroll restoration
The router sets history.scrollRestoration = 'manual' and handles scrolling itself. Defaults:
| Navigation | Behavior |
|---|---|
Forward (push / replace) | Scroll to (0, 0). |
| Traverse (back / forward button) | Restore the scroll position saved on the previous entry. |
Hash navigation (/page#section) | Scroll the element with that id into view. |
| Reload | No-op — let the browser handle it. |
Scroll work happens inside the View Transition's update callback, in the same task as the location update, so the post-commit DOM is scrolled before the animation snapshots its end state.
Opt out per call when you don't want any router-driven scrolling — useful for in-place filters and pagination that should preserve the user's position:
router.navigate({ to: nextPageUrl, scroll: false });Lazy chunks and Suspense
When the destination route is lazy(), the router awaits the chunk before startViewTransition fires, so the new DOM is in place when the transition snapshots its end state. Without that, the transition would snapshot an empty boundary and animate to nothing.
Hover-prefetch on <Link> (the default) warms the chunk earlier, so even the first hop is usually instant.
Redirects
When a beforeEnter guard returns a redirect URL, the router re-navigates to the target with replace: true semantics — kind becomes 'replace' and routeDirection is computed against the redirect destination, not the original target. This prevents history from accumulating /private → /login pairs and keeps animations correct (a slide-into-settings should not run if the guard rerouted you to /login).
Further reading
- Router API Reference — full API, route matching rules, types
