pyreon

@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.

@pyreon/routerstable

Installation

npm install @pyreon/router
bun add @pyreon/router
pnpm add @pyreon/router
yarn add @pyreon/router

Quick 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')!)
Router — signal-backed path matching

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
}
OptionTypeDefaultDescription
routesRouteRecord[]requiredRoute definitions
mode"hash" | "history""hash"URL mode
basestring""Base path for sub-path deployments (e.g. "/app"). Must start with /. Only applies in history mode.
dataEndpointstring`${base}/_pyreon/data`URL the client router fetches server-loader data from (one single-fetch request per navigation). Zero's createServer auto-mounts it.
scrollBehaviorScrollBehaviorFn | "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.
urlstring-Initial URL for SSR (when window.location is unavailable)
onError(err, route) => undefined | false-Global loader error handler. Return false to cancel navigation.
maxCacheSizenumber100Max 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[]
}
FieldTypeDescription
pathstringPath pattern with :param segments
componentComponentFn | LazyComponentComponent to render, or a lazy() wrapper
namestringOptional name for named navigation
metaRouteMetaRoute metadata (title, auth, scroll, custom fields)
redirectstring | (to) => stringRedirect target, evaluated before guards
beforeEnterNavigationGuard | NavigationGuard[]Guard(s) run before entering this route
beforeLeaveNavigationGuard | NavigationGuard[]Guard(s) run before leaving this route
aliasstring | string[]Alternative path(s) that render the same component and share guards, loaders, and metadata
childrenRouteRecord[]Nested child routes
loaderRouteLoaderFnData loader function
serverLoaderRouteLoaderFnServer-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
hasServerLoaderbooleanSerializable marker on client builds that this record has a server loader — triggers the single-fetch to the data endpoint on client navigations
staleWhileRevalidatebooleanWhen true, show cached loader data immediately and revalidate in the background
loaderKey(ctx) => stringCache-identity function for loader data. Default: path + JSON.stringify(params)
gcTimenumberTime in ms to keep cached loader data before GC. Default 300000 (5 min); 0 disables caching
errorComponentComponentFnComponent shown when the loader fails (also catches render errors)
notFoundComponentComponentFnComponent rendered for unmatched URLs under this layout (the _404.tsx "404 within layout" pattern)
pendingComponentComponentFnComponent shown while this route's loader is running
pendingMsnumberDelay in ms before showing pendingComponent (default 0) — prevents flash on fast loaders
pendingMinMsnumberMinimum 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()
middlewareRouteMiddleware | 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 exist

The 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 from params / 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. Default 300000 (5 minutes). Set to 0 to 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 bypassstaleWhileRevalidate: true routes 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 (default 0). A loader that resolves faster than pendingMs never 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 (default 200) 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:

  1. Pushes the router into Pyreon's context stack so useRouter() and useRoute() work in descendants.

  2. Sets a module-level fallback so useRouter() works from event handlers outside the component tree.

  3. 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.

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>
PropTypeDefaultDescription
tostringrequiredNavigation target path
replacebooleanfalseUse replace instead of push
activeClassstring"router-link-active"Class when link is active (current path starts with link target)
exactActiveClassstring"router-link-exact-active"Class on exact path match
exactbooleanfalseOnly 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.

StrategyTriggerUse for
"intent" (default)pointer hover and keyboard focusEverything by default. Focus coverage means keyboard + screen-reader users get the same head-start as mouse users — no extra work.
"hover"pointer hover onlyNiche: when you specifically want to exclude focus (rare).
"viewport"link scrolls within 200px of the viewport, fetched in an idle sliceLong content lists / feeds where most links are never hovered (infinite scroll, search results).
"none"neverLinks 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(): Router
import { 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>(): () => T
import { 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): () => boolean
import { 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 &#123; remove &#125; object.

onBeforeRouteLeave

In-component guard called before the user navigates away from the current route:

function onBeforeRouteLeave(guard: NavigationGuard): () => void
import { 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): () => void
import { 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>
}

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=posts

If 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 /

Guards run before navigation commits and can cancel, redirect, or allow navigation.

Guard Execution Order

  1. beforeLeave guards on the current (FROM) route's matched records

  2. beforeEnter guards on the target (TO) route's matched records

  3. Global beforeEach guards

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

ReturnEffect
undefinedAllow navigation
trueAllow navigation
falseCancel navigation
stringRedirect 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'
})
type NavigationGuardResult = boolean | string | undefined

type NavigationGuard = (
  to: ResolvedRoute,
  from: ResolvedRoute,
) => NavigationGuardResult | Promise<NavigationGuardResult>

type AfterEachHook = (to: ResolvedRoute, from: ResolvedRoute) => void

Data 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.aborted or pass signal to fetch() will be cancelled.

  • Error handling: If a loader throws and the route has an errorComponent, it is rendered instead of the route component. If no errorComponent is defined, the route component renders with undefined data.

  • 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 to throw. Catchable by the nearest NotFoundBoundary.

  • NotFoundBoundary — renders its fallback when a descendant throws notFound(). 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 },
): LazyComponent

Lazy 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") or SyntaxError, the router assumes a post-deploy stale chunk and triggers a full page reload.

  • Loading component -- Optional component shown during loading. If not provided, null is rendered.

  • Error component -- Optional component shown after all retries fail. If not provided, null is rendered.

  • LRU cache -- Resolved components are cached in a per-router Map. When the cache exceeds maxCacheSize (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 directly

Scroll Behavior

Control scroll position on navigation:

const router = createRouter({
  routes,
  scrollBehavior: 'restore',
})

Scroll Options

ValueBehavior
"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 array

stringifyQuery

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)
// null

For 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'
      }
    },
  },
]
// 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:

  1. Create the router with url: req.url

  2. Prefetch loader data with prefetchLoaderData(router, req.url)

  3. Render the app to string

  4. Serialize loader data with serializeLoaderData(router) and embed in HTML

  5. On the client, create a new router and hydrate with hydrateLoaderData(router, data)

  6. 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 URL

The 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 error

Exports Summary

Functions

functioncreateRouter
createRouter(options: RouterOptions | RouteRecord[]): Router
Create a router instance with the given options or shorthand route array.
functionlazy
lazy(loader: () => Promise<ComponentFn | { default: ComponentFn }>, options?: { loading?: ComponentFn; error?: ComponentFn }): LazyComponent
Lazy-load a route component with automatic retries and stale chunk detection.
functionresolveRoute
resolveRoute(path: string, routes: RouteRecord[]): ResolvedRoute
Resolve a raw path (including query string and hash) against the route tree.
functionbuildPath
buildPath(pattern: string, params: Record<string, string>): string
Build a path string from a route pattern and params. Handles optional params and splat params.
functionbuildNameIndex
buildNameIndex(routes: RouteRecord[]): Map<string, RouteRecord>
Pre-build a name→RouteRecord Map for O(1) named navigation lookups.
functionfindRouteByName
findRouteByName(name: string, routes: RouteRecord[]): RouteRecord | null
Find a route record by name with recursive search.
functionparseQuery
parseQuery(query: string): Record<string, string>
Parse a query string into a record of single string values. Duplicate keys are overwritten (last wins).
functionparseQueryMulti
parseQueryMulti(query: string): Record<string, string | string[]>
Parse a query string preserving duplicate keys as arrays.
functionstringifyQuery
stringifyQuery(params: Record<string, string>): string
Convert a query object to a query string with a leading '?'. Returns empty string if the object is empty.
functionprefetchLoaderData
prefetchLoaderData(router: Router, url: string, request?: Request): Promise<void>
SSR: prefetch all loader data for the matched route at the given URL. The optional request is forwarded to loaders as LoaderContext.request.
functionserializeLoaderData
serializeLoaderData(router: Router): Record<string, unknown>
SSR: serialize the router's loader data for embedding in HTML.
functionhydrateLoaderData
hydrateLoaderData(router: Router, data: Record<string, unknown>): void
Client: hydrate serialized loader data into the router so the initial render uses server-fetched data.
functionredirect
redirect(url: string, status?: RedirectStatus): never
Throw inside a loader to redirect the navigation before the layout renders. Default status 307.
functionisRedirectError
isRedirectError(err: unknown): boolean
Type guard — true when err is a redirect() sentinel. Re-throw it from custom error boundaries.
functiongetRedirectInfo
getRedirectInfo(err: unknown): { url: string; status: RedirectStatus } | null
Extract the target URL and status from a redirect() error, or null if not a redirect.
functionnotFound
notFound(message?: string): never
Throw inside a loader or component to trigger the nearest NotFoundBoundary.
functionisNotFoundError
isNotFoundError(err: unknown): boolean
Type guard — true when err is a notFound() sentinel.

Components

CRouterProvider
<RouterProvider :router='router'>...</RouterProvider>
Provide the router instance to the component tree via context.
CRouterView
<RouterView />
Render the matched route component for the current route. Nest inside layouts for nested routing.
CRouterLink
<RouterLink to="/path" activeClass="active" exactActiveClass="exact-active">...</RouterLink>
Navigation link that applies active classes and supports prefetching on hover/focus.
CNotFoundBoundary
<NotFoundBoundary fallback={() => <NotFound />}>...</NotFoundBoundary>
Renders its fallback when a descendant throws notFound().

Hooks

HuseRouter
useRouter(): Router
Access the router instance from within the component tree.
HuseRoute
useRoute(): () => ResolvedRoute
Access the current resolved route as a reactive signal.
HuseLoaderData
useLoaderData<T>(): T
Read data returned by the current route's loader function.
HuseSearchParams
useSearchParams<T>(defaults?: T): [get: () => T, set: (updates: Partial<T>) => Promise<void>]
Reactive read/write access to URL query parameters with optional defaults.
HuseTypedSearchParams
useTypedSearchParams<T>(schema: T): TypedSearchParams<T>
Type-safe search params with automatic coercion from URL strings.
HuseValidatedSearch
useValidatedSearch<T>(): () => T
Reactive accessor for the route's validateSearch result, with structural sharing.
HuseIsActive
useIsActive(path: string, exact?: boolean): () => boolean
Reactive boolean for whether a path matches the current route (segment-aware prefix match).
HuseTransition
useTransition(): () => boolean
Reactive accessor — true while a route transition is in progress.
HuseMiddlewareData
useMiddlewareData(): () => Record<string, unknown>
Reactive accessor for data set by the route's middleware chain.
HuseBlocker
useBlocker(fn: BlockerFn): { remove(): void }
Register a navigation blocker. Returns true to block, false to allow. Also handles beforeunload.
HonBeforeRouteLeave
onBeforeRouteLeave(guard: NavigationGuard): () => void
In-component guard called before navigating away from the current route.
HonBeforeRouteUpdate
onBeforeRouteUpdate(guard: NavigationGuard): () => void
In-component guard called when the route changes but the component is reused.

Context

KRouterContext
RouterContext: Context<Router>
The router context object for advanced use cases.

Types

TRouter
interface Router
The router instance interface with push, replace, back, guards, and signals.
TRouterOptions
interface RouterOptions
Options for createRouter: routes, mode, base, dataEndpoint (single-fetch data endpoint URL, default `${base}/_pyreon/data`), scrollBehavior, and url (SSR).
TRouteRecord
interface RouteRecord
Route record with path, component, name, meta, guards, loader, children, and redirect.
TRouteComponent
type RouteComponent = ComponentFn | LazyComponent
A route component: either a regular component function or a lazy-loaded component.
TLazyComponent
type LazyComponent
Lazy component wrapper returned by the lazy() helper.
TResolvedRoute
interface ResolvedRoute
A resolved route with path, params, query, hash, matched records, and merged meta.
TRouteMeta
interface RouteMeta
Route metadata interface. Extendable via TypeScript module augmentation.
TNavigationGuard
type NavigationGuard = (to: ResolvedRoute, from: ResolvedRoute) => NavigationGuardResult | Promise<NavigationGuardResult>
Guard function called before navigation commits.
TNavigationGuardResult
type NavigationGuardResult = boolean | string | undefined
Guard return type: true/undefined to allow, false to cancel, string to redirect.
TAfterEachHook
type AfterEachHook = (to: ResolvedRoute, from: ResolvedRoute) => void
Hook function called after navigation commits. Cannot affect navigation.
TLoaderContext
interface LoaderContext { params: Record<string, string>; query: Record<string, string>; signal: AbortSignal; request?: Request }
Context passed to route loader functions. request is populated during SSR AND for serverLoaders run by the data endpoint on client navigations.
TRouteLoaderFn
type RouteLoaderFn = (ctx: LoaderContext) => Promise<unknown>
Async loader function for fetching route data before navigation commits.
TRouteMiddleware
type RouteMiddleware = (ctx: RouteMiddlewareContext) => void | false | string | Promise<void | false | string>
Per-route middleware run before guards. Return false to cancel, string to redirect.
TRouteMiddlewareContext
interface RouteMiddlewareContext { to: ResolvedRoute; from: ResolvedRoute; data: Record<string, unknown> }
Context passed through the middleware chain. Accumulate state on ctx.data.
TRedirectStatus
type RedirectStatus = 301 | 302 | 303 | 307 | 308
HTTP status for redirect(). Default 307 (method-preserving).
TScrollBehaviorFn
type ScrollBehaviorFn = (to: ResolvedRoute, from: ResolvedRoute, savedPosition: number | null) => "top" | "restore" | "none" | number
Custom scroll behavior function for advanced scroll control.
TBlockerFn
type BlockerFn = (to: ResolvedRoute, from: ResolvedRoute) => boolean | Promise<boolean>
Blocker function for useBlocker. Return true to block navigation.
TExtractParams
type ExtractParams<T extends string>
Utility type that extracts typed param keys from a path pattern string.
TRouterProviderProps
interface RouterProviderProps
Props for the RouterProvider component.
TRouterViewProps
interface RouterViewProps
Props for the RouterView component.
TRouterLinkProps
interface RouterLinkProps
Props for the RouterLink component.
@pyreon/router