@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
}| 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) |
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).
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-'))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 and prefetching.
interface RouterLinkProps {
to: string
replace?: boolean
activeClass?: string
exactActiveClass?: string
exact?: boolean
prefetch?: 'intent' | 'hover' | 'viewport' | 'none'
children?: VNodeChild | null
}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 | string | required | Navigation target path |
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) |
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"
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:
push() and replace() return promises that resolve after all guards, loaders, and the navigation commit have completed. This is useful for sequential navigation:
async function handleLogin() {
await authenticate()
await router.push('/dashboard')
// Navigation is complete, dashboard is rendered
}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.
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.
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.
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