@pyreon/router is Pyreon's type-safe client-side router. It supports nested routes, TypeScript param inference from path strings, navigation guards, data loaders, lazy loading with retries, scroll restoration, and both hash and history mode.
Installation
npm install @pyreon/routerbun add @pyreon/routerpnpm add @pyreon/routeryarn add @pyreon/routerQuick Start
import { createRouter, RouterProvider, RouterView, RouterLink } from '@pyreon/router'
import { mount } from '@pyreon/runtime-dom'
const router = createRouter({
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/user/:id', component: UserPage, name: 'user' },
{ path: '(.*)', component: NotFound },
],
})
function App() {
return (
<RouterProvider router={router}>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
<RouterView />
</RouterProvider>
)
}
mount(<App />, document.getElementById('app')!)createRouter
Create a router instance. Accepts a RouterOptions object or a shorthand array of RouteRecord[].
const router = createRouter({
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
],
mode: 'history', // "hash" (default) or "history"
scrollBehavior: 'restore', // "top" | "restore" | "none" | ScrollBehaviorFn
})
// Shorthand -- just pass the routes array:
const router = createRouter([
{ path: '/', component: Home },
{ path: '/about', component: About },
])RouterOptions
interface RouterOptions {
routes: RouteRecord[]
mode?: 'hash' | 'history'
base?: string
dataEndpoint?: string
scrollBehavior?: ScrollBehaviorFn | 'top' | 'restore' | 'none'
trailingSlash?: 'strip' | 'add' | 'ignore'
url?: string
onError?: (err: unknown, route: ResolvedRoute) => undefined | false
maxCacheSize?: number
links?: LinkConfig
}| Option | Type | Default | Description |
|---|---|---|---|
routes | RouteRecord[] | required | Route definitions |
mode | "hash" | "history" | "hash" | URL mode |
base | string | "" | Base path for sub-path deployments (e.g. "/app"). Must start with /. Only applies in history mode. |
dataEndpoint | string | `${base}/_pyreon/data` | URL the client router fetches server-loader data from (one single-fetch request per navigation). Zero's createServer auto-mounts it. |
scrollBehavior | ScrollBehaviorFn | "top" | "restore" | "none" | "top" | Scroll behavior on navigation |
trailingSlash | "strip" | "add" | "ignore" | "strip" | Trailing slash handling: "strip" removes trailing slashes before matching, "add" ensures paths end with /, "ignore" does no normalization. |
url | string | - | Initial URL for SSR (when window.location is unavailable) |
onError | (err, route) => undefined | false | - | Global loader error handler. Return false to cancel navigation. |
maxCacheSize | number | 100 | Max number of resolved lazy components to cache (LRU eviction) |
links | LinkConfig | - | <RouterLink> external-link behaviour — sameOriginAbsolute ('internal' | 'external'), externalNewTab, externalRel. See External links. |
Hash mode vs history mode:
// Hash mode (default): URLs like /#/about
const router = createRouter({ routes, mode: 'hash' })
// History mode: clean URLs like /about (requires server-side fallback)
const router = createRouter({ routes, mode: 'history' })Hash mode uses window.location.hash and listens to hashchange events. History mode uses pushState/replaceState and listens to popstate events. History mode requires your server to serve the app for all routes (SPA fallback).
Browser Back/Forward runs the full navigation pipeline. A popstate/hashchange traversal goes through the same flow as router.push(): blockers, middleware, guards, and loaders all run (so useLoaderData() is populated after pressing Back), afterEach fires (the route announcer announces the change), and meta.title updates. If a guard or blocker cancels the traversal, the router restores the URL and history position — history.go() when the target entry carries the router's position stamp (history.state.__pyreonIdx), replaceState otherwise. Traversal-driven route updates are asynchronous (they await the pipeline).
Base path (sub-directory deployment):
When deploying to a sub-path like https://example.com/app/, set the base option. The router strips the base before matching and prepends it when building URLs.
const router = createRouter({
routes,
mode: 'history',
base: '/app',
})
// router.push("/about") navigates to /app/about
// URL /app/user/42 matches route /user/:id with params.id = "42"Trailing slash normalization:
// Strip trailing slashes (default) — /about/ becomes /about
createRouter({ routes, trailingSlash: 'strip' })
// Always add trailing slash — /about becomes /about/
createRouter({ routes, trailingSlash: 'add' })
// No normalization — match paths exactly as-is
createRouter({ routes, trailingSlash: 'ignore' })Error handling:
const router = createRouter({
routes,
onError: (err, route) => {
console.error(`Loader failed for ${route.path}:`, err)
// Return false to cancel the navigation
// Return undefined to continue with undefined loader data
return false
},
})Route Records
interface RouteRecord<TPath extends string = string> {
path: TPath
component: RouteComponent
name?: string
meta?: RouteMeta
redirect?: string | ((to: ResolvedRoute) => string)
beforeEnter?: NavigationGuard | NavigationGuard[]
beforeLeave?: NavigationGuard | NavigationGuard[]
alias?: string | string[]
children?: RouteRecord[]
loader?: RouteLoaderFn
serverLoader?: RouteLoaderFn
hasServerLoader?: boolean
staleWhileRevalidate?: boolean
loaderKey?: (ctx: Pick<LoaderContext, 'params' | 'query'>) => string
gcTime?: number
errorComponent?: ComponentFn
notFoundComponent?: ComponentFn
pendingComponent?: ComponentFn
pendingMs?: number
pendingMinMs?: number
validateSearch?: (raw: Record<string, string>) => Record<string, unknown>
middleware?: RouteMiddleware | RouteMiddleware[]
}| Field | Type | Description |
|---|---|---|
path | string | Path pattern with :param segments |
component | ComponentFn | LazyComponent | Component to render, or a lazy() wrapper |
name | string | Optional name for named navigation |
meta | RouteMeta | Route metadata (title, auth, scroll, custom fields) |
redirect | string | (to) => string | Redirect target, evaluated before guards |
beforeEnter | NavigationGuard | NavigationGuard[] | Guard(s) run before entering this route |
beforeLeave | NavigationGuard | NavigationGuard[] | Guard(s) run before leaving this route |
alias | string | string[] | Alternative path(s) that render the same component and share guards, loaders, and metadata |
children | RouteRecord[] | Nested child routes |
loader | RouteLoaderFn | Data loader function |
serverLoader | RouteLoaderFn | Server-only data loader — present as a real function ONLY in the SSR module graph (zero's .server.ts sibling convention); never ships to the client. A record must not have both loader and serverLoader |
hasServerLoader | boolean | Serializable marker on client builds that this record has a server loader — triggers the single-fetch to the data endpoint on client navigations |
staleWhileRevalidate | boolean | When true, show cached loader data immediately and revalidate in the background |
loaderKey | (ctx) => string | Cache-identity function for loader data. Default: path + JSON.stringify(params) |
gcTime | number | Time in ms to keep cached loader data before GC. Default 300000 (5 min); 0 disables caching |
errorComponent | ComponentFn | Component shown when the loader fails (also catches render errors) |
notFoundComponent | ComponentFn | Component rendered for unmatched URLs under this layout (the _404.tsx "404 within layout" pattern) |
pendingComponent | ComponentFn | Component shown while this route's loader is running |
pendingMs | number | Delay in ms before showing pendingComponent (default 0) — prevents flash on fast loaders |
pendingMinMs | number | Minimum display time in ms for pendingComponent once shown (default 200) — prevents flicker |
validateSearch | (raw) => Record<string, unknown> | Validate/transform raw query params into typed values. Result on route.search / useValidatedSearch() |
middleware | RouteMiddleware | RouteMiddleware[] | Per-route middleware — runs before guards, can accumulate context data |
Path Patterns
The router supports several path pattern types:
Static paths:
{ path: "/about", component: About }
{ path: "/contact", component: Contact }Dynamic param segments:
Segments prefixed with : capture the corresponding path segment as a named parameter.
{ path: "/user/:id", component: UserPage }
// /user/42 => params.id = "42"
{ path: "/user/:id/posts/:postId", component: PostPage }
// /user/42/posts/7 => params.id = "42", params.postId = "7"Splat (catch-rest) params:
Params ending with * capture the rest of the path, including slashes.
{ path: "/files/:path*", component: FileBrowser }
// /files/docs/readme.md => params.path = "docs/readme.md"
// /files/images/logo.png => params.path = "images/logo.png"Optional params:
Params suffixed with ? match zero or one segments. The param type becomes string | undefined.
{ path: "/user/:id?", component: UserPage }
// /user => params.id = undefined
// /user/42 => params.id = "42"
{ path: "/page/:page?/settings/:setting?", component: SettingsPage }
// /page/settings => params.page = undefined, params.setting = undefined
// /page/1/settings/theme => params.page = "1", params.setting = "theme"Optional params work with ExtractParams type inference:
type Params = ExtractParams<'/user/:id?'>
// { id?: string | undefined }When building paths with buildPath(), optional segments are omitted when no value is provided:
buildPath('/user/:id?', {}) // "/user"
buildPath('/user/:id?', { id: '42' }) // "/user/42"Wildcard (catch-all):
The pattern (.*) matches any path. Use it as the last route for 404 pages.
{ path: "(.*)", component: NotFound }
// or equivalently:
{ path: "*", component: NotFound }Route matching order:
Routes are matched in definition order. The first match wins. Place more specific routes before less specific ones:
const routes = [
{ path: '/', component: Home },
{ path: '/user/me', component: MyProfile }, // specific
{ path: '/user/:id', component: UserProfile }, // dynamic
{ path: '/user/:id/posts', component: UserPosts },
{ path: '(.*)', component: NotFound }, // catch-all last
]TypeScript Param Inference
Route params are automatically inferred from the path string using the ExtractParams type:
import { useRoute } from '@pyreon/router'
// Inside a route with path "/user/:id/posts/:postId":
const route = useRoute<'/user/:id/posts/:postId'>()
route().params.id // string (typed!)
route().params.postId // string (typed!)
// route().params.foo // TypeScript error -- "foo" does not existThe ExtractParams utility type works at compile time:
import type { ExtractParams } from '@pyreon/router'
type UserParams = ExtractParams<'/user/:id'>
// { id: string }
type PostParams = ExtractParams<'/user/:id/posts/:postId'>
// { id: string; postId: string }
type FileParams = ExtractParams<'/files/:path*'>
// { path: string }
type HomeParams = ExtractParams<'/'>
// Record<never, never> (empty object)Nested Routes
Child routes are rendered inside the parent's component via a nested RouterView. The parent route's path acts as a prefix for all children.
const routes = [
{
path: '/admin',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: 'users', component: AdminUsers }, // matches /admin/users
{ path: 'settings', component: AdminSettings }, // matches /admin/settings
{ path: 'users/:id', component: AdminUserDetail }, // matches /admin/users/42
],
},
]
function AdminLayout() {
return (
<div class="admin">
<Sidebar />
<main>
<RouterView /> {/* renders AdminUsers, AdminSettings, or AdminUserDetail */}
</main>
</div>
)
}Multi-level nesting:
Routes can be nested to any depth. Each level needs its own RouterView.
const routes = [
{
path: '/dashboard',
component: DashboardLayout,
children: [
{
path: 'analytics',
component: AnalyticsLayout,
children: [
{ path: 'overview', component: AnalyticsOverview }, // /dashboard/analytics/overview
{ path: 'reports', component: AnalyticsReports }, // /dashboard/analytics/reports
],
},
{ path: 'settings', component: DashboardSettings }, // /dashboard/settings
],
},
]
function DashboardLayout() {
return (
<div class="dashboard">
<DashboardNav />
<RouterView /> {/* level 1: AnalyticsLayout or DashboardSettings */}
</div>
)
}
function AnalyticsLayout() {
return (
<div class="analytics">
<AnalyticsTabs />
<RouterView /> {/* level 2: AnalyticsOverview or AnalyticsReports */}
</div>
)
}How depth tracking works:
Each RouterView captures router._viewDepth at setup time and increments it, so sibling and child views get the correct index into the matched[] array. When a RouterView unmounts, the counter decrements. This means each RouterView automatically renders the correct nesting level without any manual configuration.
Route Metadata
Attach metadata to routes via the meta property. Metadata is merged from root to leaf -- deeper routes override parent values.
interface RouteMeta {
title?: string // Sets document.title on navigation
description?: string // Page description (for meta tags)
requiresAuth?: boolean // Guards can check this
scrollBehavior?: 'top' | 'restore' | 'none'
}const routes = [
{
path: '/admin',
component: AdminLayout,
meta: { requiresAuth: true, title: 'Admin' },
children: [
{ path: 'users', component: AdminUsers, meta: { title: 'Admin - Users' } },
{ path: 'settings', component: AdminSettings, meta: { title: 'Admin - Settings' } },
],
},
]
// Navigating to /admin/users:
// route.meta = { requiresAuth: true, title: "Admin - Users" }
// (title from child overrides parent, requiresAuth is inherited)The router automatically sets document.title when meta.title is present.
Extending RouteMeta via module augmentation:
Add custom fields to RouteMeta for your app:
// globals.d.ts
declare module '@pyreon/router' {
interface RouteMeta {
requiresRole?: 'admin' | 'user' | 'guest'
breadcrumb?: string
transition?: 'fade' | 'slide'
cacheable?: boolean
}
}Then use the custom fields in your route definitions:
const routes = [
{
path: '/admin',
component: AdminLayout,
meta: {
requiresAuth: true,
requiresRole: 'admin',
breadcrumb: 'Admin',
},
children: [
{
path: 'users',
component: AdminUsers,
meta: { breadcrumb: 'Users', transition: 'slide' },
},
],
},
]Redirects
// Static redirect
{ path: "/old-page", redirect: "/new-page" }
// Dynamic redirect with access to the resolved route
{ path: "/legacy/:id", redirect: (to) => `/v2/${to.params.id}` }Redirects are evaluated before guards. The router detects circular redirects (max depth 10) and aborts with a console error.
const routes = [
// Redirect /home to /
{ path: '/home', redirect: '/' },
// Redirect with param forwarding
{ path: '/profile/:id', redirect: (to) => `/user/${to.params.id}` },
// Redirect preserving query params
{ path: '/search-old', redirect: (to) => `/search?q=${to.query.q ?? ''}` },
// Actual routes
{ path: '/', component: Home },
{ path: '/user/:id', component: UserPage },
{ path: '/search', component: SearchPage },
]Route Aliases
Aliases let a route be reachable from multiple paths. The alias renders the same component and shares guards, loaders, and metadata with the original route.
const routes = [
{
path: '/user/:id',
component: UserProfile,
alias: '/profile/:id', // single alias
},
{
path: '/settings',
component: Settings,
alias: ['/preferences', '/config'], // multiple aliases
},
]Aliases are useful for backwards compatibility (keeping old URLs working) and providing multiple entry points to the same view.
Stale While Revalidate
When staleWhileRevalidate: true is set on a route with a loader, the router shows cached data immediately on navigation and revalidates in the background. Once fresh data arrives, the component re-renders.
const routes = [
{
path: '/feed',
component: Feed,
loader: async ({ signal }) => {
const res = await fetch('/api/feed', { signal })
return res.json()
},
staleWhileRevalidate: true,
},
]This only applies when navigating to a route that already has cached loader data (e.g., the user previously visited it). On the first visit, the loader runs normally and navigation waits for it to complete.
Loader Cache
Loader results are cached by key with in-flight deduplication and TTL-based garbage collection. This avoids re-fetching data the user just navigated away from and back to.
const routes = [
{
path: '/user/:id',
component: UserProfile,
loader: async ({ params, signal }) => {
const res = await fetch(`/api/users/${params.id}`, { signal })
return res.json()
},
// Cache identity — same key = served from cache, loader skipped
loaderKey: ({ params }) => `user-${params.id}`,
// Keep cached data 10 minutes before garbage collection (default: 5 min)
gcTime: 10 * 60 * 1000,
},
]loaderKey— derives the cache key fromparams/query. Two navigations producing the same key resolve from cache without re-running the loader. Default:path + JSON.stringify(params).gcTime— milliseconds to keep a cached entry before it is garbage-collected. Default300000(5 minutes). Set to0to disable caching for the route.In-flight dedup — two concurrent navigations to the same key share a single loader promise. Aborted in-flight entries are skipped so a fresh navigation never inherits a cancelled fetch.
SWR bypass —
staleWhileRevalidate: trueroutes always re-run the loader for revalidation; the cache only seeds the immediate stale render.
Invalidate cached loader data imperatively via the router:
const router = useRouter()
// Invalidate ALL cached loader data
router.invalidateLoader()
// Invalidate by exact cache key (as returned by loaderKey)
router.invalidateLoader('user-42')
// Invalidate entries where a predicate matches the key
router.invalidateLoader((key) => key.startsWith('user-'))invalidateLoader only takes effect on the next navigation. To refresh the current route's data in place — the mutation-then-refresh flow — use revalidate():
// After a mutation, re-run the current route's loaders and re-render
// the affected components with fresh data. No navigation required.
await api.deletePost(id)
await router.revalidate()revalidate() drops the current chain's cached loader entries and re-runs its loaders through the normal pipeline: server-loader records re-fetch from the data endpoint, staleWhileRevalidate records refresh in the background per their contract, and a loader that throws redirect() navigates (replace semantics). A real navigation starting mid-revalidate supersedes it.
After invalidation the next navigation to the affected route re-runs the loader instead of serving the stale entry.
Pending Components
Show a placeholder while a route's loader runs, without flashing on fast loads:
const routes = [
{
path: '/dashboard',
component: Dashboard,
loader: loadDashboard,
pendingComponent: DashboardSkeleton,
pendingMs: 150, // wait 150ms before showing the skeleton (default: 0)
pendingMinMs: 400, // once shown, keep it for at least 400ms (default: 200)
},
]pendingComponent— rendered while this route's loader is in flight.pendingMs— delay before the pending component appears (default0). A loader that resolves faster thanpendingMsnever shows the placeholder, so quick navigations don't flash a skeleton.pendingMinMs— once the pending component is shown, it stays for at least this long (default200) to avoid a jarring flicker if data arrives a moment later.
Internally this is a signal-driven state machine: hidden → pending → ready.
Validated Search Params
Transform raw query strings into typed, validated values per route. validateSearch accepts any (raw: Record<string, string>) => T function — a plain function, a Zod .parse, a Valibot parser, etc.
const routes = [
{
path: '/search',
component: SearchPage,
// Plain function
validateSearch: (raw) => ({
page: Number(raw.page) || 1,
q: raw.q ?? '',
}),
},
{
path: '/products',
component: Products,
// With Zod
validateSearch: z.object({
page: z.coerce.number().default(1),
sort: z.enum(['name', 'price']).default('name'),
}).parse,
},
]The validated result is available on route.search and, with full type inference, via the useValidatedSearch<T>() hook (see Hooks).
Components
RouterProvider
Wraps your app and provides the router instance to all descendant components via Pyreon's context system.
interface RouterProviderProps {
router: Router
children?: VNode | VNodeChild | null
}<RouterProvider router={router}>
<App />
</RouterProvider>RouterProvider does several things:
Pushes the router into Pyreon's context stack so
useRouter()anduseRoute()work in descendants.Sets a module-level fallback so
useRouter()works from event handlers outside the component tree.Cleans up on unmount: removes event listeners, clears caches, aborts in-flight navigations.
RouterView
Renders the matched route component at the current nesting level. Place one at each level of your route nesting.
interface RouterViewProps {
router?: Router // optional -- uses context by default
}function App() {
return (
<RouterProvider router={router}>
<RouterView />
</RouterProvider>
)
}
// In a nested layout:
function AdminLayout() {
return (
<div>
<Sidebar />
<RouterView /> {/* renders the matched child route */}
</div>
)
}RouterView handles lazy-loaded components automatically: it shows the loading component while the chunk loads, retries on failure, and shows the error component after all retries are exhausted.
When a route has a loader, RouterView wraps the route component with a LoaderDataContext provider so useLoaderData() works inside.
RouterLink
A reactive link component that renders an <a> tag with automatic active class management, prefetching, typed route paths, and automatic external-link handling.
interface RouterLinkProps<T extends string = string> {
to: CheckHref<T>
replace?: boolean
activeClass?: string
exactActiveClass?: string
exact?: boolean
prefetch?: 'intent' | 'hover' | 'viewport' | 'none'
// External-link overrides (auto-detected from `to`; set these to force a choice):
external?: boolean
target?: string
rel?: string
children?: VNodeChild | null
}to is validated by CheckHref<T>: when routes are registered (see Typed routes) a mistyped internal path is a compile error, while dynamic strings and external URLs are always accepted with no cast.
Basic usage:
<RouterLink to="/about">About</RouterLink>With all props:
<RouterLink
to="/users"
activeClass="nav-active"
exactActiveClass="nav-exact"
exact
prefetch="viewport"
replace
>
Users
</RouterLink>| Prop | Type | Default | Description |
|---|---|---|---|
to | CheckHref<T> | required | Navigation target — a route path, dynamic string, or external URL |
replace | boolean | false | Use replace instead of push |
activeClass | string | "router-link-active" | Class when link is active (current path starts with link target) |
exactActiveClass | string | "router-link-exact-active" | Class on exact path match |
exact | boolean | false | Only apply activeClass on exact match |
prefetch | "intent" | "hover" | "viewport" | "none" | "intent" | Prefetch strategy for loader data (default prefetches on hover and keyboard focus) |
external | boolean | auto | Force external (true) or internal (false), overriding auto-detection |
target | string | auto ("_blank" for external) | Override the <a target> (auto "_blank" for external links) |
rel | string | auto ("noopener noreferrer") | Override the <a rel> (auto secure rel on new-tab links) |
Active class behavior:
The active class is segment-aware. /admin is a prefix of /admin/users but NOT of /admin-panel.
// Current path: /admin/users
<RouterLink to="/admin">Admin</RouterLink>
// class="router-link-active" (prefix match)
<RouterLink to="/admin/users">Users</RouterLink>
// class="router-link-active router-link-exact-active" (exact match)
<RouterLink to="/">Home</RouterLink>
// No active class (/ is not applied as prefix by default)Navigation behavior:
RouterLink renders a standard <a> tag with an onClick handler that calls e.preventDefault() and uses router.push() (or router.replace() if replace is set). The href attribute is set correctly for both hash and history mode:
Hash mode:
href="#/about"History mode:
href="/about"
The router is resolved the same way every router hook resolves it — the nearest <RouterProvider> context first, falling back to the active router (setActiveRouter()), so links rendered outside the provider subtree (portals, detached trees) still client-navigate when a router is active.
Without any router (no <RouterProvider> ancestor and no active router), RouterLink degrades to a plain anchor: the href is the plain path (/about — no hash fallback) and clicks are NOT intercepted, so the browser performs a full-load navigation instead of the link being dead. In development it also warns once per destination:
[Pyreon] <RouterLink to="/about"> rendered without a RouterProvider — falling back
to a plain anchor (full page load on click). Wrap the tree in <RouterProvider router={…}>.Full-reload warning for plain <a href> (dev only)
A plain internal <a href="/about"> in a router app triggers a full page reload — almost never what you want. In development, the router registers a document-level click listener (removed by router.destroy()) that warns when a plain internal anchor is about to full-reload:
[Pyreon] internal <a href="/about"> triggers a full page reload — use
<RouterLink to="/about"> for client-side navigation. (Deliberate full-load
link? add target/download, or data-allow-reload.)<RouterLink> clicks never warn (the link preventDefault()s the internal clicks it handles, which is the discriminator). External URLs, mailto:/tel:, #hash anchors, and modifier/middle clicks are skipped. The warning applies in both history and hash mode — a path-style href full-reloads a hash-mode app too, while valid #/x hrefs are skipped.
For a deliberate full-load link, opt out with any of:
<a href="/legacy-page" data-allow-reload>Old server-rendered page</a>
<a href="/report.pdf" download>Download</a>
<a href="/print-view" target="_blank">Print view</a>External links
RouterLink inspects to at runtime and only intercepts internal navigations. Anything else is left to the browser, with the right <a> attributes applied automatically:
to shape | Kind | Rendered <a> | Click |
|---|---|---|---|
/about, about | internal | href="#/about" (hash) / href="/about" (history) | router.push() |
https://x.com/…, //cdn.x.com/… | external | target="_blank" rel="noopener noreferrer" | full browser navigation (new tab) |
mailto:, tel:, sms:, other schemes | protocol | plain <a href> | browser handles it |
#section | hash | plain <a href="#section"> | browser scroll |
https://this-site.com/about (same origin) | internal by default | stripped to href="#/about" | router.push() |
<RouterLink to="/docs">Docs</RouterLink> {/* client nav */}
<RouterLink to="https://github.com/pyreon">GitHub</RouterLink> {/* new tab, secure rel */}
<RouterLink to="mailto:hi@pyreon.dev">Email</RouterLink> {/* browser mailto */}Modifier / middle-clicks (⌘/Ctrl/Shift/Alt, non-primary button) always fall through to the browser's native open-in-new-tab behaviour, even on internal links.
Configure globally via createRouter({ links }) (RouterOptions):
createRouter({
routes,
links: {
sameOriginAbsolute: 'internal', // or 'external' — how to treat same-origin absolute URLs (default 'internal')
externalNewTab: true, // external links open in a new tab (default true)
externalRel: 'noopener noreferrer', // rel applied to new-tab links (default)
},
})Override per link — explicit props win over config and auto-detection:
<RouterLink to="https://x.com" target="_self">stay here</RouterLink>
<RouterLink to="/report.pdf" external>open PDF in new tab</RouterLink>
<RouterLink to="https://this-site.com/a" external={false}>client-nav a same-origin absolute</RouterLink>Typed routes
By default to is string — the router is fully usable with no codegen. Register your app's routes by augmenting the RegisteredRoutes interface (a build step like @pyreon/zero's typedRoutes can emit this for you) and to gains autocomplete + typo-rejection, while still accepting dynamic strings and external URLs:
declare module '@pyreon/router' {
interface RegisteredRoutes {
'/': Record<string, never>
'/resume': Record<string, never>
'/user/:id': { id: string }
}
}
<RouterLink to="/resume" /> // ✓ autocompleted
<RouterLink to="/user/42" /> // ✓ concrete path matches the `:id` pattern
<RouterLink to="/rezume" /> // ✗ TS error: did you mean '/resume'?
<RouterLink to="/users/42" /> // ✗ TS error: wrong prefix (it's /user/:id)
<RouterLink to={someString} /> // ✓ dynamic string, no cast
<RouterLink to="https://x.com" /> // ✓ external URL, never typo-checkedConcrete paths validate against :param patterns via InterpolateRoute (/user/:id accepts /user/42, /user/anything, but not /users/42).
The generic RouterLink<const T> infers T from the to literal and runs it through CheckHref<T>: a literal that looks internal but isn't registered collapses to the route union (producing the error), while registered routes / external URLs / dynamic strings pass through unchanged. This is a strict superset of to: string — nothing that compiled before stops compiling.
Prefetch Strategies
Prefetching runs the target route's loader in advance so data is ready the moment the user navigates. This is on by default — every <RouterLink> prefetches on hover and keyboard focus unless you opt out. You do not need to do anything to get instant-feeling navigation; the table below is for tuning, not enabling.
| Strategy | Trigger | Use for |
|---|---|---|
"intent" (default) | pointer hover and keyboard focus | Everything by default. Focus coverage means keyboard + screen-reader users get the same head-start as mouse users — no extra work. |
"hover" | pointer hover only | Niche: when you specifically want to exclude focus (rare). |
"viewport" | link scrolls within 200px of the viewport, fetched in an idle slice | Long content lists / feeds where most links are never hovered (infinite scroll, search results). |
"none" | never | Links the user is unlikely to click (legal/footer), or where the loader is expensive and speculative fetching wastes server load. |
// Default — no prefetch prop needed. Hover OR keyboard-focus this link
// and its loader data is already resolving before the click lands.
<RouterLink to="/about">About</RouterLink>
// Explicit intent (same as the default — shown for clarity)
<nav>
<RouterLink to="/" prefetch="intent">Home</RouterLink>
<RouterLink to="/dashboard" prefetch="intent">Dashboard</RouterLink>
</nav>
// Content feed — prefetch as links scroll near the viewport. The
// observer uses a 200px rootMargin so the fetch starts BEFORE the link
// is fully on screen, and runs in a requestIdleCallback slice so it
// never competes with the scroll the user is performing.
<ul>
{posts.map(post => (
<li>
<RouterLink to={`/post/${post.id}`} prefetch="viewport">
{post.title}
</RouterLink>
</li>
))}
</ul>
// Opt out for a link the user rarely clicks
<RouterLink to="/terms" prefetch="none">Terms of Service</RouterLink>Why "intent" is the default (and why it matters for accessibility): hover-only prefetch silently excludes keyboard and screen-reader users — they focus a link before activating it, never hover it. "intent" covers both, so the perceived-latency win (typically 100-300ms off the next navigation) is delivered to all users, not just mouse users, with zero configuration.
Prefetching is deduplicated per router instance and bounded: each path is prefetched at most once, and the prefetch set is capped (oldest-evicted) so a long-lived SPA navigating across many routes can't grow it unboundedly. Prefetch is best-effort — a failed prefetch is silently dropped and the real navigation re-runs the loader normally.
Hooks
useRouter
Access the router instance from any component inside a RouterProvider:
function useRouter(): Routerimport { useRouter } from '@pyreon/router'
function MyComponent() {
const router = useRouter()
const goHome = () => router.push('/')
const goBack = () => router.back()
const isLoading = () => router.loading()
return (
<div>
<button onClick={goHome}>Home</button>
<button onClick={goBack}>Back</button>
{isLoading() && <span>Loading...</span>}
</div>
)
}Throws if called outside a RouterProvider.
useRoute
Access the current resolved route as a reactive signal:
function useRoute<TPath extends string = string>(): () => ResolvedRoute<ExtractParams<TPath>>import { useRoute } from '@pyreon/router'
function Breadcrumb() {
const route = useRoute()
return (
<nav>
<span>{route().path}</span>
{Object.entries(route().query).map(([k, v]) => (
<span>
{k}={v}
</span>
))}
</nav>
)
}With typed params:
function UserProfile() {
const route = useRoute<'/user/:id'>()
return <p>User ID: {route().params.id}</p>
// route().params.id is typed as string
}The resolved route includes:
interface ResolvedRoute<
P extends Record<string, string> = Record<string, string>,
Q extends Record<string, string> = Record<string, string>,
> {
path: string // The matched path (without query or hash)
params: P // Extracted route params
query: Q // Parsed query string
hash: string // Hash fragment (without #)
matched: RouteRecord[] // All matched records from root to leaf
meta: RouteMeta // Merged metadata from all matched records
}useSearchParams
Reactive read/write access to URL query parameters:
function useSearchParams<T extends Record<string, string>>(
defaults?: T,
): [get: () => T, set: (updates: Partial<T>) => Promise<void>]import { useSearchParams } from '@pyreon/router'
function SearchPage() {
const [query, setQuery] = useSearchParams({ q: '', page: '1' })
return (
<div>
<input value={query().q} onInput={(e) => setQuery({ q: e.currentTarget.value })} />
<p>Page: {query().page}</p>
<button onClick={() => setQuery({ page: String(Number(query().page) + 1) })}>
Next Page
</button>
</div>
)
}The defaults object provides fallback values for missing query params. set() navigates to the current path with updated params (existing params are preserved, only specified keys are changed).
useValidatedSearch
Reactive accessor for the current route's validated search params (configured via the route's validateSearch). Returns the typed result with structural sharing — a shallow-equal check prevents re-renders when unrelated query params change.
function useValidatedSearch<T>(): () => Timport { useValidatedSearch } from '@pyreon/router'
// Route: { path: '/search', validateSearch: (raw) => ({ page: Number(raw.page) || 1, q: raw.q ?? '' }) }
function SearchPage() {
const search = useValidatedSearch<{ page: number; q: string }>()
return (
<div>
<p>Query: {search().q}</p>
<p>Page: {search().page}</p>
</div>
)
}Returns an empty object when the matched route has no validateSearch configured. Pair it with the route's validateSearch (see Validated Search Params) — that function is the single source of truth for the shape T.
useIsActive
Returns a reactive boolean for whether a path matches the current route. Useful for custom active-state styling outside of RouterLink.
function useIsActive(path: string, exact?: boolean): () => booleanimport { useIsActive } from '@pyreon/router'
function NavItem(props: { to: string; label: string }) {
const isActive = useIsActive(props.to)
return (
<a href={props.to} class={isActive() ? 'nav-item active' : 'nav-item'}>
{props.label}
</a>
)
}Matching is segment-aware: /admin matches /admin/users (prefix) when exact is falsy, but never matches /admin-panel. Pass exact: true to require an exact path match.
useBlocker
Register a navigation blocker to prevent the user from leaving a page:
function useBlocker(fn: BlockerFn): { remove(): void }
type BlockerFn = (to: ResolvedRoute, from: ResolvedRoute) => boolean | Promise<boolean>Return true (or resolve to true) to block navigation. The blocker also installs a beforeunload handler to catch tab closures.
import { useBlocker } from '@pyreon/router'
function Editor() {
const dirty = signal(false)
useBlocker((to, from) => {
if (!dirty()) return false
return !window.confirm('You have unsaved changes. Leave anyway?')
})
return <textarea onInput={() => dirty.set(true)} />
}The blocker is automatically removed when the component unmounts. You can also remove it manually via the returned { remove } object.
onBeforeRouteLeave
In-component guard called before the user navigates away from the current route:
function onBeforeRouteLeave(guard: NavigationGuard): () => voidimport { onBeforeRouteLeave } from '@pyreon/router'
function EditorPage() {
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges()) {
return false // cancel navigation
}
})
return <div>...</div>
}Returns an unregister function. Automatically cleaned up on component unmount.
onBeforeRouteUpdate
In-component guard called when the route changes but the component is reused (e.g., /user/1 to /user/2):
function onBeforeRouteUpdate(guard: NavigationGuard): () => voidimport { onBeforeRouteUpdate } from '@pyreon/router'
function UserProfile() {
onBeforeRouteUpdate(async (to, from) => {
// Confirm before switching to a different user
if (to.params.id !== from.params.id) {
const ok = window.confirm(`Switch to user ${to.params.id}?`)
if (!ok) return false
}
})
return <div>...</div>
}Navigation
Programmatic Navigation
The router provides several navigation methods:
const router = useRouter()
// Navigate by path
await router.push('/user/42')
// Navigate by name with params
await router.push({ name: 'user', params: { id: '42' } })
// Navigate by name with query
await router.push({ name: 'search', query: { q: 'pyreon' } })
// Replace current history entry (no new entry in browser history)
await router.replace('/new-path')
// Replace with named route
await router.replace({ name: 'user', params: { id: '42' } })
// Go back / forward / arbitrary delta
router.back()
router.forward()
router.go(-2) // go back 2 steps
router.go(1) // same as forward()Named navigation:
Named routes avoid hardcoding paths. Give routes a name and navigate with an object:
const routes = [
{ path: '/user/:id', component: UserPage, name: 'user' },
{ path: '/user/:id/posts/:postId', component: PostPage, name: 'post' },
]
// Navigate by name
router.push({ name: 'user', params: { id: '42' } })
// => /user/42
router.push({ name: 'post', params: { id: '42', postId: '7' } })
// => /user/42/posts/7
// With query params
router.push({ name: 'user', params: { id: '42' }, query: { tab: 'posts' } })
// => /user/42?tab=postsIf a named route does not exist, the router logs a warning and navigates to /.
Navigation is async — and reports what it did:
push() and replace() return promises that resolve after all guards, loaders, and the navigation commit have completed — with a NavigationResult telling you what happened:
async function handleLogin() {
await authenticate()
const result = await router.push('/dashboard')
// 'committed' — the route (or a redirect it chained into) is now current
// 'cancelled' — a blocker / guard / middleware refused the navigation
// 'superseded' — a newer navigation started while this one was in flight
if (result !== 'committed') showToast('Navigation was blocked')
}Callers that ignore the value keep working — await router.push(...) still settles exactly when the navigation settles.
Security:
The router blocks javascript: and data: URIs in navigation targets. Attempting to navigate to such a URI logs a warning and redirects to /.
router.push('javascript:alert(1)') // blocked, navigates to /
router.push('data:text/html,...') // blocked, navigates to /Navigation Guards
Guards run before navigation commits and can cancel, redirect, or allow navigation.
Guard Execution Order
beforeLeaveguards on the current (FROM) route's matched recordsbeforeEnterguards on the target (TO) route's matched recordsGlobal
beforeEachguards
Each group runs in order. If any guard cancels or redirects, subsequent guards do not run.
Global Guards
// Before each navigation
const removeGuard = router.beforeEach(async (to, from) => {
// Check authentication
if (to.meta.requiresAuth && !isAuthenticated()) {
return '/login' // redirect to login
}
// return undefined or true to allow
// return false to cancel
})
// Remove the guard later
removeGuard()// After each navigation (cannot cancel or redirect)
const removeHook = router.afterEach((to, from) => {
analytics.trackPageView(to.path)
})
removeHook()afterEach hooks run after the navigation has committed. They receive to and from resolved routes but cannot affect the navigation. Errors thrown in afterEach hooks are caught and logged.
Per-Route Guards
const routes = [
{
path: '/admin',
component: AdminLayout,
beforeEnter: (to, from) => {
if (!isAdmin()) return '/unauthorized'
},
beforeLeave: (to, from) => {
if (hasUnsavedChanges()) return false // cancel navigation
},
},
]Multiple guards per route:
{
path: "/admin/settings",
component: AdminSettings,
beforeEnter: [
// Guard 1: check auth
(to, from) => {
if (!isAuthenticated()) return "/login"
},
// Guard 2: check role
(to, from) => {
if (!isAdmin()) return "/unauthorized"
},
],
}Guard Return Values
| Return | Effect |
|---|---|
undefined | Allow navigation |
true | Allow navigation |
false | Cancel navigation |
string | Redirect to that path |
Async Guards
Guards can be async. The router awaits each guard before proceeding. If a newer navigation starts while guards are running, the current navigation is cancelled (stale generation detection).
router.beforeEach(async (to, from) => {
// Async check
const allowed = await checkPermission(to.path)
if (!allowed) return '/forbidden'
})Navigation Guard Types
type NavigationGuardResult = boolean | string | undefined
type NavigationGuard = (
to: ResolvedRoute,
from: ResolvedRoute,
) => NavigationGuardResult | Promise<NavigationGuardResult>
type AfterEachHook = (to: ResolvedRoute, from: ResolvedRoute) => voidData Loaders
Route loaders run before navigation commits, in parallel with sibling loaders. The result is accessible via useLoaderData() inside the route component.
const routes = [
{
path: "/users",
component: Users,
loader: async ({ params, query, signal }) => {
const res = await fetch("/api/users", { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
},
errorComponent: UsersError, // shown if loader fails
},
]
function Users() {
const users = useLoaderData<User[]>()
return (
<ul>
{users.map(u => <li>{u.name}</li>)}
</ul>
)
}
function UsersError() {
return <p>Failed to load users. Please try again.</p>
}LoaderContext
interface LoaderContext {
params: Record<string, string> // Route params
query: Record<string, string> // Query string params
signal: AbortSignal // Aborted when a newer navigation starts
request?: Request // The incoming HTTP Request — server-side runs only, undefined for CSR loaders
}The signal is crucial for cancellation: if the user navigates away before the loader finishes, the signal is aborted. Always pass it to fetch() and other async operations.
request is populated when the loader runs during SSR (via prefetchLoaderData(router, path, request)) AND for serverLoaders run by the data endpoint on client-side navigations — it is undefined for an isomorphic loader running in the browser. This lets server-side loaders read cookies / auth headers and decide whether to throw redirect('/login') before the layout renders:
loader: ({ request }) => {
const cookie = request?.headers.get('cookie') ?? ''
const sid = cookie.match(/sid=([^;]+)/)?.[1]
if (!sid) throw redirect('/login')
return loadSession(sid)
}Loader Behavior
Parallel execution: All loaders in the matched route stack run in parallel via
Promise.allSettled.Cancellation: When a new navigation starts, the previous navigation's AbortController is aborted. Loaders that check
signal.abortedor passsignaltofetch()will be cancelled.Error handling: If a loader throws and the route has an
errorComponent, it is rendered instead of the route component. If noerrorComponentis defined, the route component renders withundefineddata.Data cleanup: Loader data for routes no longer in the matched stack is pruned after each navigation.
Loaders with Route Params
const routes = [
{
path: "/user/:id",
component: UserProfile,
loader: async ({ params, signal }) => {
const res = await fetch(`/api/users/${params.id}`, { signal })
return res.json()
},
},
{
path: "/search",
component: SearchResults,
loader: async ({ query, signal }) => {
const res = await fetch(`/api/search?q=${query.q ?? ""}`, { signal })
return res.json()
},
},
]
function UserProfile() {
const user = useLoaderData<User>()
return <h1>{user.name}</h1>
}
function SearchResults() {
const results = useLoaderData<SearchResult[]>()
return (
<ul>
{results.map(r => <li>{r.title}</li>)}
</ul>
)
}Nested Route Loaders
Each route in the matched stack can have its own loader. All loaders run in parallel.
const routes = [
{
path: '/org/:orgId',
component: OrgLayout,
loader: async ({ params, signal }) => {
const res = await fetch(`/api/orgs/${params.orgId}`, { signal })
return res.json()
},
children: [
{
path: 'members',
component: OrgMembers,
loader: async ({ params, signal }) => {
const res = await fetch(`/api/orgs/${params.orgId}/members`, { signal })
return res.json()
},
},
],
},
]
function OrgLayout() {
const org = useLoaderData<Organization>()
return (
<div>
<h1>{org.name}</h1>
<RouterView />
</div>
)
}
function OrgMembers() {
const members = useLoaderData<Member[]>()
return (
<ul>
{members.map((m) => (
<li>{m.name}</li>
))}
</ul>
)
}SSR Data Loaders
For SSR, prefetch loader data before rendering, serialize it into the HTML, and hydrate it on the client:
Server:
import { createRouter, prefetchLoaderData, serializeLoaderData } from '@pyreon/router'
import { renderToString } from '@pyreon/runtime-server'
// In your SSR handler:
const router = createRouter({ routes, url: req.url })
// Run all loaders for the matched route
await prefetchLoaderData(router, req.url)
// Render the app
const html = await renderToString(<App />)
// Serialize loader data for client hydration
const loaderData = JSON.stringify(serializeLoaderData(router))
// Include in HTML response:
const page = `
<!DOCTYPE html>
<html>
<body>
<div id="app">${html}</div>
<script>window.__PYREON_LOADER_DATA__=${loaderData}</script>
<script src="/client.js"></script>
</body>
</html>
`Client:
import { createRouter, hydrateLoaderData } from '@pyreon/router'
import { mount } from '@pyreon/runtime-dom'
const router = createRouter({ routes })
// Hydrate loader data so initial render uses server-fetched data
hydrateLoaderData(router, window.__PYREON_LOADER_DATA__ ?? {})
mount(<App />, document.getElementById('app')!)serializeLoaderData uses route path patterns as keys (stable across server and client). hydrateLoaderData populates the router's internal _loaderData map so the initial render uses server-fetched data without re-running loaders.
Server loaders + single-fetch
A record can carry a serverLoader instead of a loader — a data loader that exists as a real function ONLY in the SSR module graph (zero emits it from the .server.ts sibling convention; client builds carry just the serializable hasServerLoader marker). On a client-side navigation to a chain with hasServerLoader records, the router makes ONE fetch to the data endpoint (dataEndpoint, default `${base}/_pyreon/data`) for the whole matched chain — single-fetch, no per-record waterfall. The endpoint's worker calls router.runServerLoaders(path, request) server-side. A redirect() thrown from a server loader comes back as a JSON envelope at HTTP 200, which the client router turns into a navigation. See Zero → Server Loaders for the file convention.
redirect() — control-flow from inside a loader
Throw redirect(url, status?) inside a route loader to redirect the navigation before the layout renders. This is the canonical pattern for SSR-side auth gates and replaces the fragile onMount + router.push('/login') workaround under nested-layout dev SSR + hydration (which would briefly render the auth-gated layout before redirecting, leaking authenticated UI structure to anonymous users).
import { redirect, type LoaderContext } from '@pyreon/router'
export const loader = async ({ request, params }: LoaderContext) => {
const session = await getSession(request)
if (!session) {
throw redirect('/login?next=' + encodeURIComponent(params.path), 307)
}
return loadUser(session.userId)
}Default status is 307 (Temporary Redirect, method-preserving). Use 301 for permanent redirects, 302 for legacy GET-coercing behavior.
SSR-side: @pyreon/server's handler catches the thrown error and returns a real HTTP 302 / 307 Location: Response — no layout HTML leaks server-side.
CSR-side: the router's loader-runner catches the redirect and propagates as router.replace(target) with redirect-depth bookkeeping (max 10 hops, matches the route-record redirect: field).
Companion guards (for custom error boundaries that should let redirects pass through):
import { isRedirectError, getRedirectInfo } from '@pyreon/router'
<ErrorBoundary
fallback={(err) => {
if (isRedirectError(err)) throw err // re-throw, let the router handle it
return <div>Error: {err.message}</div>
}}
>
<App />
</ErrorBoundary>getRedirectInfo(err) returns { url, status } for further inspection. Most apps don't need to touch these — the framework handles redirects transparently — but they're available for advanced control flow.
LoaderContext.request is populated when a loader runs during SSR (via prefetchLoaderData(router, path, request)) AND for serverLoaders run by the data endpoint on client navigations; undefined for isomorphic loaders running in the browser. This lets server-side loaders read cookies / auth headers and decide whether to redirect() BEFORE the layout renders.
notFound() — trigger a 404 boundary
Throw notFound() inside a loader or a component to render the nearest NotFoundBoundary instead of the route. This is the "the route matched but the resource doesn't exist" case (e.g. /user/999 where user 999 was deleted) — distinct from "no route matched at all".
import { notFound, NotFoundBoundary, isNotFoundError } from '@pyreon/router'
const routes = [
{
path: '/user/:id',
component: UserProfile,
loader: async ({ params, signal }) => {
const res = await fetch(`/api/users/${params.id}`, { signal })
if (res.status === 404) throw notFound()
return res.json()
},
},
]
function App() {
return (
<RouterProvider router={router}>
<NotFoundBoundary fallback={() => <h1>404 — Not Found</h1>}>
<RouterView />
</NotFoundBoundary>
</RouterProvider>
)
}notFound()— returns a sentinel error tothrow. Catchable by the nearestNotFoundBoundary.NotFoundBoundary— renders itsfallbackwhen a descendant throwsnotFound(). Behaves like an error boundary scoped to not-found errors.isNotFoundError(err)— type guard for custom error boundaries that need to distinguish not-found from other errors (re-throw everything else, render a 404 UI for this one).
<ErrorBoundary
fallback={(err) => {
if (isNotFoundError(err)) return <NotFound />
if (isRedirectError(err)) throw err // let the router handle redirects
return <GenericError error={err} />
}}
>
<RouterView />
</ErrorBoundary>The _404.tsx convention (no-route-matched)
When no route matches the URL at all, the router renders the deepest matched layout's notFoundComponent as a synthetic leaf — so the 404 page carries the same chrome (header, nav, footer) as a normal page. With @pyreon/zero's fs-router this is wired automatically by dropping a _404.tsx (or _not-found.tsx) file next to your _layout.tsx; the scanner attaches its default export as the parent layout's notFoundComponent.
The resolved route exposes isNotFound: true when this synthetic fallback fired, which SSR handlers read to emit an HTTP 404 status:
interface ResolvedRoute {
// ...
search?: Record<string, unknown> // validated search params (validateSearch)
isNotFound?: boolean // true when a notFoundComponent fallback rendered
}notFound() (the thrown sentinel) and notFoundComponent / _404.tsx (the no-route-matched fallback) are complementary: the first is for "matched route, missing resource", the second for "URL matched nothing".
Lazy Loading
Use the lazy() helper for code-splitting route components:
import { lazy } from '@pyreon/router'
const routes = [
{
path: '/dashboard',
component: lazy(() => import('./pages/Dashboard'), {
loading: LoadingSpinner, // shown while loading
error: LoadError, // shown if all retries fail
}),
},
{
path: '/settings',
component: lazy(() => import('./pages/Settings')),
// No loading or error component -- renders null while loading
},
]function lazy(
loader: () => Promise<ComponentFn | { default: ComponentFn }>,
options?: { loading?: ComponentFn; error?: ComponentFn },
): LazyComponentLazy Loading Features
Automatic retries -- 3 attempts with exponential backoff (500ms, 1s, 2s).
Stale chunk detection -- If a chunk fails with a
TypeError("Failed to fetch") orSyntaxError, the router assumes a post-deploy stale chunk and triggers a full page reload.Loading component -- Optional component shown during loading. If not provided,
nullis rendered.Error component -- Optional component shown after all retries fail. If not provided,
nullis rendered.LRU cache -- Resolved components are cached in a per-router
Map. When the cache exceedsmaxCacheSize(default 100), the oldest entry is evicted.
function LoadingSpinner() {
return <div class="spinner">Loading...</div>
}
function LoadError() {
return (
<div class="error">
<p>Failed to load this page.</p>
<button onClick={() => window.location.reload()}>Reload</button>
</div>
)
}Lazy Component Resolution
The lazy() loader function should return either a component function directly or a module with a default export:
// Default export (standard ESM module)
lazy(() => import('./pages/Dashboard'))
// The router extracts mod.default
// Direct component export
lazy(() => import('./pages/Dashboard').then((m) => m.DashboardPage))
// The router uses the function directlyScroll Behavior
Control scroll position on navigation:
const router = createRouter({
routes,
scrollBehavior: 'restore',
})Scroll Options
| Value | Behavior |
|---|---|
"top" (default) | Scroll to top on every navigation |
"restore" | Restore saved scroll position, fall back to top |
"none" | Don't touch scroll position |
The scroll manager saves window.scrollY before each navigation and restores it after the navigation commits.
Per-Route Scroll Override
Override the global scroll behavior for specific routes via metadata:
{ path: "/modal", component: Modal, meta: { scrollBehavior: "none" } }
{ path: "/settings", component: Settings, meta: { scrollBehavior: "restore" } }Per-route meta.scrollBehavior takes precedence over the global setting.
Custom Scroll Function
For advanced control, pass a function:
type ScrollBehaviorFn = (
to: ResolvedRoute,
from: ResolvedRoute,
savedPosition: number | null,
) => 'top' | 'restore' | 'none' | number
const router = createRouter({
routes,
scrollBehavior: (to, from, savedPosition) => {
// Restore position for back/forward navigation
if (savedPosition !== null) return 'restore'
// Don't scroll for hash navigation
if (to.hash) return 'none'
// Scroll to a specific position
if (to.path === '/long-page') return 500
// Default: scroll to top
return 'top'
},
})The savedPosition parameter is null if the target path has no saved position (i.e., it is a new visit, not a back/forward navigation).
Returning a number scrolls to that exact pixel offset from the top.
Router API
The full Router interface:
interface Router {
/** Navigate to a path or named route */
push(path: string): Promise<void>
push(location: {
name: string
params?: Record<string, string>
query?: Record<string, string>
}): Promise<void>
/** Replace current history entry */
replace(path: string): Promise<void>
replace(location: {
name: string
params?: Record<string, string>
query?: Record<string, string>
}): Promise<void>
/** Go back in history */
back(): void
/** Go forward in history */
forward(): void
/** Navigate by delta steps in history */
go(delta: number): void
/** Register a global before-navigation guard. Returns an unregister function. */
beforeEach(guard: NavigationGuard): () => void
/** Register a global after-navigation hook. Returns an unregister function. */
afterEach(hook: AfterEachHook): () => void
/** Current resolved route (reactive signal -- call it to read) */
readonly currentRoute: () => ResolvedRoute
/** True while a navigation (guards + loaders) is in flight */
readonly loading: () => boolean
/** Promise that resolves once the initial navigation completes */
isReady(): Promise<void>
/**
* Resolve `path`, load any lazy route components into the cache, and run
* the matched routes' loaders. After this resolves, a `RouterView`
* rendered for `path` produces final HTML synchronously. Used by SSR/SSG.
* `request` is forwarded to loaders as `LoaderContext.request`.
* `options.skipLoaders: true` resolves lazy components but skips loaders
* entirely (used by SSG 404 generation, where there's no real request).
*/
preload(
path: string,
request?: Request,
options?: { skipLoaders?: boolean },
): Promise<void>
/**
* Run ONLY the matched chain's serverLoaders for `path`, keyed by
* matched-chain index. Returns the index-keyed data, or a redirect
* descriptor when a server loader threw redirect(). Server-only —
* the `serverLoader` fn exists only in the SSR module graph. This is
* what the single-fetch data endpoint's worker calls.
*/
runServerLoaders(
path: string,
request?: Request,
): Promise<
| { kind: 'data'; data: Record<number, unknown> }
| { kind: 'redirect'; to: string; status: number }
>
/**
* Invalidate cached loader data. Forces loaders to re-run on next nav.
* - no args: invalidate ALL cached loader data
* - string: invalidate by cache key (as returned by loaderKey)
* - function: invalidate entries where the predicate returns true
*/
invalidateLoader(keyOrPredicate?: string | ((key: string) => boolean)): void
/** Remove all event listeners, clear caches, abort in-flight navigations */
destroy(): void
}Router is generic over the route-name union for type-safe named navigation — Router<TNames>. See Typed Route Names.
The loading signal:
router.loading() returns true while a navigation is in progress (guards running, loaders fetching). Use it to show global loading indicators:
function App() {
const router = useRouter()
return (
<div>
{router.loading() && <div class="global-loading-bar" />}
<RouterView />
</div>
)
}The isReady method:
isReady() returns a promise that resolves once the initial navigation (including guards and loaders) completes. Use it to delay rendering until the router is fully initialized:
const router = createRouter({ routes })
await router.isReady()
mount(<App />, document.getElementById('app')!)The destroy method:
Call destroy() to clean up the router: remove popstate/hashchange listeners, clear component and loader caches, abort in-flight navigations. RouterProvider calls destroy() automatically on unmount, so you typically do not need to call it manually.
Utility Functions
Query String Utilities
import { parseQuery, parseQueryMulti, stringifyQuery } from '@pyreon/router'parseQuery
Parses a query string into a Record<string, string>. Duplicate keys are overwritten (last wins).
parseQuery('name=Alice&age=30')
// { name: "Alice", age: "30" }
parseQuery('key=value&empty&encoded=%20hello')
// { key: "value", empty: "", encoded: " hello" }
parseQuery('')
// {}parseQueryMulti
Parses a query string preserving duplicate keys as arrays. Single-value keys remain strings.
parseQueryMulti('color=red&color=blue&size=lg')
// { color: ["red", "blue"], size: "lg" }
parseQueryMulti('tag=a&tag=b&tag=c')
// { tag: ["a", "b", "c"] }
parseQueryMulti('single=value')
// { single: "value" } -- not wrapped in an arraystringifyQuery
Converts a query object to a query string with a leading ?. Returns an empty string if the object is empty.
stringifyQuery({ name: 'Alice', age: '30' })
// "?name=Alice&age=30"
stringifyQuery({ q: 'hello world' })
// "?q=hello%20world"
stringifyQuery({})
// ""Route Resolution Utilities
import { resolveRoute, buildPath, findRouteByName } from '@pyreon/router'resolveRoute
Resolve a raw path (including query string and hash) against the route tree. Returns a ResolvedRoute.
const routes = [{ path: '/user/:id', component: User, name: 'user' }]
const resolved = resolveRoute('/user/42?tab=posts#section', routes)
// {
// path: "/user/42",
// params: { id: "42" },
// query: { tab: "posts" },
// hash: "section",
// matched: [{ path: "/user/:id", ... }],
// meta: {}
// }If no route matches, returns an empty resolved route:
const resolved = resolveRoute('/nonexistent', routes)
// { path: "/nonexistent", params: {}, query: {}, hash: "", matched: [], meta: {} }buildPath
Build a path string from a route pattern and params. Encodes param values.
buildPath('/user/:id', { id: '42' })
// "/user/42"
buildPath('/user/:id/posts/:postId', { id: '42', postId: '7' })
// "/user/42/posts/7"
// Splat params preserve slashes
buildPath('/files/:path*', { path: 'docs/readme.md' })
// "/files/docs/readme.md"findRouteByName
Find a route record by name (recursive search, O(n)). Returns null if not found.
const routes = [
{ path: '/', component: Home },
{ path: '/user/:id', component: User, name: 'user' },
{
path: '/admin',
component: Admin,
children: [{ path: 'settings', component: Settings, name: 'admin-settings' }],
},
]
findRouteByName('user', routes)
// { path: "/user/:id", component: User, name: "user" }
findRouteByName('admin-settings', routes)
// { path: "settings", component: Settings, name: "admin-settings" }
findRouteByName('nonexistent', routes)
// nullFor repeated lookups (e.g., inside navigation), the router internally uses buildNameIndex() which creates an O(1) name-to-record Map at startup.
Real-World Patterns
Authentication-Protected Routes
// routes.ts
const routes = [
{ path: '/login', component: LoginPage },
{
path: '/dashboard',
component: DashboardLayout,
meta: { requiresAuth: true },
children: [
{ path: 'overview', component: Overview },
{ path: 'settings', component: Settings },
],
},
{ path: '/', redirect: '/dashboard/overview' },
{ path: '(.*)', component: NotFound },
]
// auth.ts
import { createRouter } from '@pyreon/router'
const router = createRouter({ routes, mode: 'history' })
// Global auth guard
router.beforeEach(async (to, from) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
// Save the intended destination for post-login redirect
sessionStorage.setItem('redirect', to.path)
return '/login'
}
})
// After login, redirect to saved destination
async function handleLogin(credentials: Credentials) {
await authenticate(credentials)
const redirect = sessionStorage.getItem('redirect') || '/dashboard/overview'
sessionStorage.removeItem('redirect')
await router.push(redirect)
}Role-Based Access Control
// Extend RouteMeta
declare module '@pyreon/router' {
interface RouteMeta {
requiredRole?: 'admin' | 'editor' | 'viewer'
}
}
const routes = [
{
path: '/admin',
component: AdminPanel,
meta: { requiresAuth: true, requiredRole: 'admin' },
beforeEnter: (to, from) => {
const user = getCurrentUser()
if (user?.role !== to.meta.requiredRole) {
return '/unauthorized'
}
},
},
]Breadcrumbs
// Extend RouteMeta with breadcrumb labels
declare module '@pyreon/router' {
interface RouteMeta {
breadcrumb?: string
}
}
const routes = [
{
path: '/products',
component: ProductsLayout,
meta: { breadcrumb: 'Products' },
children: [
{ path: '', component: ProductList },
{
path: ':id',
component: ProductDetail,
meta: { breadcrumb: 'Details' },
children: [{ path: 'reviews', component: ProductReviews, meta: { breadcrumb: 'Reviews' } }],
},
],
},
]
function Breadcrumbs() {
const route = useRoute()
return (
<nav class="breadcrumbs">
{route()
.matched.filter((r) => r.meta?.breadcrumb)
.map((r, i, arr) => (
<span>
{i > 0 && ' / '}
{i < arr.length - 1 ? (
<RouterLink to={buildPath(r.path, route().params)}>{r.meta!.breadcrumb}</RouterLink>
) : (
<span>{r.meta!.breadcrumb}</span>
)}
</span>
))}
</nav>
)
}404 Handling
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
// Catch-all: must be last
{ path: '(.*)', component: NotFoundPage, meta: { title: 'Page Not Found' } },
]
function NotFoundPage() {
const route = useRoute()
const router = useRouter()
return (
<div class="not-found">
<h1>404 - Page Not Found</h1>
<p>
The page <code>{route().path}</code> does not exist.
</p>
<button onClick={() => router.push('/')}>Go Home</button>
</div>
)
}Unsaved Changes Warning
const routes = [
{
path: '/editor',
component: Editor,
beforeLeave: (to, from) => {
// Check for unsaved changes before navigating away
if (hasUnsavedChanges()) {
const confirmed = window.confirm('You have unsaved changes. Leave anyway?')
if (!confirmed) return false // cancel navigation
}
},
},
]Nested Layout with Shared Sidebar
const routes = [
{
path: '/app',
component: AppLayout,
children: [
{ path: 'inbox', component: Inbox, meta: { title: 'Inbox' } },
{ path: 'sent', component: Sent, meta: { title: 'Sent' } },
{ path: 'drafts', component: Drafts, meta: { title: 'Drafts' } },
{
path: 'settings',
component: SettingsLayout,
children: [
{ path: 'profile', component: ProfileSettings },
{ path: 'notifications', component: NotificationSettings },
],
},
],
},
]
function AppLayout() {
return (
<div class="app-layout">
<aside class="sidebar">
<RouterLink to="/app/inbox">Inbox</RouterLink>
<RouterLink to="/app/sent">Sent</RouterLink>
<RouterLink to="/app/drafts">Drafts</RouterLink>
<RouterLink to="/app/settings/profile">Settings</RouterLink>
</aside>
<main>
<RouterView />
</main>
</div>
)
}
function SettingsLayout() {
return (
<div class="settings-layout">
<nav class="settings-tabs">
<RouterLink to="/app/settings/profile">Profile</RouterLink>
<RouterLink to="/app/settings/notifications">Notifications</RouterLink>
</nav>
<RouterView />
</div>
)
}Analytics Tracking
const router = createRouter({ routes })
router.afterEach((to, from) => {
// Track page views
analytics.page({
path: to.path,
title: to.meta.title,
referrer: from.path,
})
})Loading Indicator
function GlobalLoadingBar() {
const router = useRouter()
return (
<div
class="loading-bar"
style={{
opacity: router.loading() ? 1 : 0,
transition: 'opacity 200ms',
}}
/>
)
}Conditional Redirect Based on State
const routes = [
{
path: '/onboarding',
redirect: () => {
const step = getOnboardingStep()
if (step === 'complete') return '/dashboard'
return `/onboarding/step-${step}`
},
},
{ path: '/onboarding/step-1', component: OnboardingStep1 },
{ path: '/onboarding/step-2', component: OnboardingStep2 },
{ path: '/onboarding/step-3', component: OnboardingStep3 },
]SSR Considerations
Server-Side Routing
On the server, pass the request URL to createRouter via the url option:
const router = createRouter({ routes, url: req.url })This is necessary because window.location is unavailable on the server. The router uses url to resolve the initial route.
Context Isolation
RouterProvider pushes the router into Pyreon's context stack. In SSR with @pyreon/runtime-server, contexts are isolated per request via AsyncLocalStorage, so concurrent requests do not share state.
The module-level _activeRouter fallback is set by RouterProvider for CSR convenience (so useRouter() works from event handlers outside the component tree). In concurrent SSR, always use the context-based approach (i.e., ensure useRouter() is called within a component tree wrapped by RouterProvider).
SSR Lifecycle
A typical SSR flow:
Create the router with
url: req.urlPrefetch loader data with
prefetchLoaderData(router, req.url)Render the app to string
Serialize loader data with
serializeLoaderData(router)and embed in HTMLOn the client, create a new router and hydrate with
hydrateLoaderData(router, data)Mount the app
// Server
import { createRouter, prefetchLoaderData, serializeLoaderData } from '@pyreon/router'
import { renderToString } from '@pyreon/runtime-server'
export async function handleRequest(req: Request): Promise<Response> {
const router = createRouter({ routes, url: new URL(req.url).pathname })
await prefetchLoaderData(router, new URL(req.url).pathname)
const html = await renderToString(
<RouterProvider router={router}>
<RouterView />
</RouterProvider>,
)
const loaderData = JSON.stringify(serializeLoaderData(router))
return new Response(
`
<!DOCTYPE html>
<html>
<body>
<div id="app">${html}</div>
<script>window.__PYREON_LOADER_DATA__=${loaderData}</script>
<script type="module" src="/client.js"></script>
</body>
</html>
`,
{ headers: { 'content-type': 'text/html' } },
)
}Typed Search Params
useTypedSearchParams provides type-safe access to URL query parameters with automatic coercion:
import { useTypedSearchParams } from '@pyreon/router'
const params = useTypedSearchParams({
page: 'number',
q: 'string',
active: 'boolean',
})
params.page() // number (coerced from URL string)
params.q() // string
params.active() // boolean
params.set({ page: 2, q: 'hello' }) // updates URLThe type map supports 'string', 'number', and 'boolean'. Values are coerced automatically from URL search param strings.
Route Transitions
useTransition returns a reactive accessor that is true while a route transition is in progress:
import { useTransition } from '@pyreon/router'
function App() {
const isTransitioning = useTransition()
return (
<div>
{isTransitioning() && <ProgressBar />}
<RouterView />
</div>
)
}useTransition() returns () => boolean directly (not an object). The accessor is true from the start of navigation (guard evaluation, loader fetching) until the route component is mounted.
Accessibility — route announcements
In a single-page app, navigation swaps content without a full page load, so screen readers are never told the "page" changed — a click on a link can appear to do nothing to a screen-reader user. The router closes this gap automatically: on every navigation, the root <RouterView> writes the new page's name into a visually-hidden aria-live="polite" region, so assistive tech announces it (the same pattern Next.js, Remix and gov.uk ship).
Zero config — it's on by default; nothing to mount or wire.
What's announced — the new
document.title(which@pyreon/headhas updated by the time navigation commits), falling back to the pathname when there's no title.When — only on genuine path changes. The initial page load isn't announced (the screen reader already reads the freshly-loaded page), and same-path query/hash changes are ignored.
Only the root view announces — nested
<RouterView>s (layouts) don't double-announce.
Opt out (e.g. if you run your own announcer) on the root view:
<RouterView announceRouteChanges={false} />For focus management on route change (moving keyboard focus to the new content), place your own focus target — a framework-level default is a separate, opt-in concern so it never fights an app that manages focus itself.
View Transitions API
Route navigations are automatically wrapped in document.startViewTransition() when the browser supports the View Transitions API. This provides smooth CSS-driven transitions between pages with zero configuration.
Reduced motion is respected automatically. When the user's OS is set to reduce motion (@media (prefers-reduced-motion: reduce)), the router skips the View Transition and swaps the DOM synchronously instead — the navigation still happens, only the fade/slide animation is suppressed. This satisfies WCAG 2.3.3 "Animation from Interactions" with no configuration. The preference is read per-navigation, so toggling it mid-session takes effect on the next route change.
To opt out for a specific route, set meta.viewTransition: false:
{
path: '/modal',
component: ModalPage,
meta: { viewTransition: false },
}The ::view-transition-old(root) and ::view-transition-new(root) CSS pseudo-elements can be styled for custom transition effects:
::view-transition-old(root) {
animation: fade-out 200ms ease-in;
}
::view-transition-new(root) {
animation: fade-in 300ms ease-out;
}Hash Scrolling
When navigating to a URL with a hash fragment (e.g., /docs#installation), the router automatically scrolls to the element with the matching id. This works for both initial page load and client-side navigation.
Route Error Boundaries
The errorComponent on a route record catches render errors (not just loader errors). When a route component throws during rendering, the error component is shown instead:
{
path: '/dashboard',
component: Dashboard,
errorComponent: (props) => (
<div>
<h2>Dashboard Error</h2>
<p>{props.error.message}</p>
<button onClick={props.reset}>Retry</button>
</div>
),
}Middleware Chain
Routes can define middleware that runs before guards and loaders. Middleware receives a context object with a data property for passing data downstream:
import type { RouteMiddleware } from '@pyreon/router'
const authMiddleware: RouteMiddleware = async (ctx) => {
const user = await getUser(ctx.request)
ctx.data.user = user
if (!user) return '/login' // redirect
}
const routes = [
{
path: '/admin',
component: AdminLayout,
middleware: [authMiddleware],
children: [
{ path: 'dashboard', component: AdminDashboard },
],
},
]Inside components, read middleware data with useMiddlewareData():
import { useMiddlewareData } from '@pyreon/router'
function AdminDashboard() {
const data = useMiddlewareData() // () => Record<string, unknown>
const user = () => data().user as User
return <h1>Welcome, {user().name}</h1>
}useMiddlewareData() returns a reactive accessor () => Record<string, unknown> — call it to read the accumulated middleware data, and cast individual keys at the use site. It is not generic and does not return the object directly. The data is per-navigation: it resets to {} when a navigation whose matched chain has no middleware commits.
Typed Route Names
Router<TNames> accepts a generic for typed named navigation:
type RouteNames = 'home' | 'user' | 'settings'
const router = createRouter<RouteNames>({
routes: [
{ path: '/', component: Home, name: 'home' },
{ path: '/user/:id', component: User, name: 'user' },
{ path: '/settings', component: Settings, name: 'settings' },
],
})
// Typed — only 'home' | 'user' | 'settings' allowed:
router.push({ name: 'user', params: { id: '42' } })
// router.push({ name: 'invalid' }) // TypeScript errorExports Summary
Functions
createRoutercreateRouter(options: RouterOptions | RouteRecord[]): Routerlazylazy(loader: () => Promise<ComponentFn | { default: ComponentFn }>, options?: { loading?: ComponentFn; error?: ComponentFn }): LazyComponentresolveRouteresolveRoute(path: string, routes: RouteRecord[]): ResolvedRoutebuildPathbuildPath(pattern: string, params: Record<string, string>): stringbuildNameIndexbuildNameIndex(routes: RouteRecord[]): Map<string, RouteRecord>findRouteByNamefindRouteByName(name: string, routes: RouteRecord[]): RouteRecord | nullparseQueryparseQuery(query: string): Record<string, string>parseQueryMultiparseQueryMulti(query: string): Record<string, string | string[]>stringifyQuerystringifyQuery(params: Record<string, string>): stringprefetchLoaderDataprefetchLoaderData(router: Router, url: string, request?: Request): Promise<void>serializeLoaderDataserializeLoaderData(router: Router): Record<string, unknown>hydrateLoaderDatahydrateLoaderData(router: Router, data: Record<string, unknown>): voidredirectredirect(url: string, status?: RedirectStatus): neverisRedirectErrorisRedirectError(err: unknown): booleangetRedirectInfogetRedirectInfo(err: unknown): { url: string; status: RedirectStatus } | nullnotFoundnotFound(message?: string): neverisNotFoundErrorisNotFoundError(err: unknown): booleanComponents
RouterProvider<RouterProvider :router='router'>...</RouterProvider>RouterView<RouterView />RouterLink<RouterLink to="/path" activeClass="active" exactActiveClass="exact-active">...</RouterLink>NotFoundBoundary<NotFoundBoundary fallback={() => <NotFound />}>...</NotFoundBoundary>Hooks
useRouteruseRouter(): RouteruseRouteuseRoute(): () => ResolvedRouteuseLoaderDatauseLoaderData<T>(): TuseSearchParamsuseSearchParams<T>(defaults?: T): [get: () => T, set: (updates: Partial<T>) => Promise<void>]useTypedSearchParamsuseTypedSearchParams<T>(schema: T): TypedSearchParams<T>useValidatedSearchuseValidatedSearch<T>(): () => TuseIsActiveuseIsActive(path: string, exact?: boolean): () => booleanuseTransitionuseTransition(): () => booleanuseMiddlewareDatauseMiddlewareData(): () => Record<string, unknown>useBlockeruseBlocker(fn: BlockerFn): { remove(): void }onBeforeRouteLeaveonBeforeRouteLeave(guard: NavigationGuard): () => voidonBeforeRouteUpdateonBeforeRouteUpdate(guard: NavigationGuard): () => voidContext
RouterContextRouterContext: Context<Router>Types
Routerinterface RouterRouterOptionsinterface RouterOptionsRouteRecordinterface RouteRecordRouteComponenttype RouteComponent = ComponentFn | LazyComponentLazyComponenttype LazyComponentResolvedRouteinterface ResolvedRouteRouteMetainterface RouteMetaNavigationGuardtype NavigationGuard = (to: ResolvedRoute, from: ResolvedRoute) => NavigationGuardResult | Promise<NavigationGuardResult>NavigationGuardResulttype NavigationGuardResult = boolean | string | undefinedAfterEachHooktype AfterEachHook = (to: ResolvedRoute, from: ResolvedRoute) => voidLoaderContextinterface LoaderContext { params: Record<string, string>; query: Record<string, string>; signal: AbortSignal; request?: Request }RouteLoaderFntype RouteLoaderFn = (ctx: LoaderContext) => Promise<unknown>RouteMiddlewaretype RouteMiddleware = (ctx: RouteMiddlewareContext) => void | false | string | Promise<void | false | string>RouteMiddlewareContextinterface RouteMiddlewareContext { to: ResolvedRoute; from: ResolvedRoute; data: Record<string, unknown> }RedirectStatustype RedirectStatus = 301 | 302 | 303 | 307 | 308ScrollBehaviorFntype ScrollBehaviorFn = (to: ResolvedRoute, from: ResolvedRoute, savedPosition: number | null) => "top" | "restore" | "none" | numberBlockerFntype BlockerFn = (to: ResolvedRoute, from: ResolvedRoute) => boolean | Promise<boolean>ExtractParamstype ExtractParams<T extends string>RouterProviderPropsinterface RouterProviderPropsRouterViewPropsinterface RouterViewPropsRouterLinkPropsinterface RouterLinkProps