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
  links?: LinkConfig
}
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)
linksLinkConfig-<RouterLink> external-link behaviour — sameOriginAbsolute ('internal' | 'external'), externalNewTab, externalRel. See External links.

Hash mode vs history mode:

// Hash mode (default): URLs like /#/about
const router = createRouter({ routes, mode: 'hash' })

// History mode: clean URLs like /about (requires server-side fallback)
const router = createRouter({ routes, mode: 'history' })

Hash mode uses window.location.hash and listens to hashchange events. History mode uses pushState/replaceState and listens to popstate events. History mode requires your server to serve the app for all routes (SPA fallback).

Browser Back/Forward runs the full navigation pipeline. A popstate/hashchange traversal goes through the same flow as router.push(): blockers, middleware, guards, and loaders all run (so useLoaderData() is populated after pressing Back), afterEach fires (the route announcer announces the change), and meta.title updates. If a guard or blocker cancels the traversal, the router restores the URL and history position — history.go() when the target entry carries the router's position stamp (history.state.__pyreonIdx), replaceState otherwise. Traversal-driven route updates are asynchronous (they await the pipeline).

Base path (sub-directory deployment):

When deploying to a sub-path like https://example.com/app/, set the base option. The router strips the base before matching and prepends it when building URLs.

const router = createRouter({
  routes,
  mode: 'history',
  base: '/app',
})

// router.push("/about") navigates to /app/about
// URL /app/user/42 matches route /user/:id with params.id = "42"

Trailing slash normalization:

// Strip trailing slashes (default) — /about/ becomes /about
createRouter({ routes, trailingSlash: 'strip' })

// Always add trailing slash — /about becomes /about/
createRouter({ routes, trailingSlash: 'add' })

// No normalization — match paths exactly as-is
createRouter({ routes, trailingSlash: 'ignore' })

Error handling:

const router = createRouter({
  routes,
  onError: (err, route) => {
    console.error(`Loader failed for ${route.path}:`, err)
    // Return false to cancel the navigation
    // Return undefined to continue with undefined loader data
    return false
  },
})

Route Records

interface RouteRecord<TPath extends string = string> {
  path: TPath
  component: RouteComponent
  name?: string
  meta?: RouteMeta
  redirect?: string | ((to: ResolvedRoute) => string)
  beforeEnter?: NavigationGuard | NavigationGuard[]
  beforeLeave?: NavigationGuard | NavigationGuard[]
  alias?: string | string[]
  children?: RouteRecord[]
  loader?: RouteLoaderFn
  serverLoader?: RouteLoaderFn
  hasServerLoader?: boolean
  staleWhileRevalidate?: boolean
  loaderKey?: (ctx: Pick<LoaderContext, 'params' | 'query'>) => string
  gcTime?: number
  errorComponent?: ComponentFn
  notFoundComponent?: ComponentFn
  pendingComponent?: ComponentFn
  pendingMs?: number
  pendingMinMs?: number
  validateSearch?: (raw: Record<string, string>) => Record<string, unknown>
  middleware?: RouteMiddleware | RouteMiddleware[]
}
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-'))

invalidateLoader only takes effect on the next navigation. To refresh the current route's data in place — the mutation-then-refresh flow — use revalidate():

// After a mutation, re-run the current route's loaders and re-render
// the affected components with fresh data. No navigation required.
await api.deletePost(id)
await router.revalidate()

revalidate() drops the current chain's cached loader entries and re-runs its loaders through the normal pipeline: server-loader records re-fetch from the data endpoint, staleWhileRevalidate records refresh in the background per their contract, and a loader that throws redirect() navigates (replace semantics). A real navigation starting mid-revalidate supersedes it.

After invalidation the next navigation to the affected route re-runs the loader instead of serving the stale entry.

Pending Components

Show a placeholder while a route's loader runs, without flashing on fast loads:

const routes = [
  {
    path: '/dashboard',
    component: Dashboard,
    loader: loadDashboard,
    pendingComponent: DashboardSkeleton,
    pendingMs: 150, // wait 150ms before showing the skeleton (default: 0)
    pendingMinMs: 400, // once shown, keep it for at least 400ms (default: 200)
  },
]
  • pendingComponent — rendered while this route's loader is in flight.

  • pendingMs — delay before the pending component appears (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, prefetching, typed route paths, and automatic external-link handling.

interface RouterLinkProps<T extends string = string> {
  to: CheckHref<T>
  replace?: boolean
  activeClass?: string
  exactActiveClass?: string
  exact?: boolean
  prefetch?: 'intent' | 'hover' | 'viewport' | 'none'
  // External-link overrides (auto-detected from `to`; set these to force a choice):
  external?: boolean
  target?: string
  rel?: string
  children?: VNodeChild | null
}

to is validated by CheckHref<T>: when routes are registered (see Typed routes) a mistyped internal path is a compile error, while dynamic strings and external URLs are always accepted with no cast.

Basic usage:

<RouterLink to="/about">About</RouterLink>

With all props:

<RouterLink
  to="/users"
  activeClass="nav-active"
  exactActiveClass="nav-exact"
  exact
  prefetch="viewport"
  replace
>
  Users
</RouterLink>
PropTypeDefaultDescription
toCheckHref<T>requiredNavigation target — a route path, dynamic string, or external URL
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)
externalbooleanautoForce external (true) or internal (false), overriding auto-detection
targetstringauto ("_blank" for external)Override the <a target> (auto "_blank" for external links)
relstringauto ("noopener noreferrer")Override the <a rel> (auto secure rel on new-tab links)

Active class behavior:

The active class is segment-aware. /admin is a prefix of /admin/users but NOT of /admin-panel.

// Current path: /admin/users

<RouterLink to="/admin">Admin</RouterLink>
// class="router-link-active" (prefix match)

<RouterLink to="/admin/users">Users</RouterLink>
// class="router-link-active router-link-exact-active" (exact match)

<RouterLink to="/">Home</RouterLink>
// No active class (/ is not applied as prefix by default)

Navigation behavior:

RouterLink renders a standard <a> tag with an onClick handler that calls e.preventDefault() and uses router.push() (or router.replace() if replace is set). The href attribute is set correctly for both hash and history mode:

  • Hash mode: href="#/about"

  • History mode: href="/about"

The router is resolved the same way every router hook resolves it — the nearest <RouterProvider> context first, falling back to the active router (setActiveRouter()), so links rendered outside the provider subtree (portals, detached trees) still client-navigate when a router is active.

Without any router (no <RouterProvider> ancestor and no active router), RouterLink degrades to a plain anchor: the href is the plain path (/about — no hash fallback) and clicks are NOT intercepted, so the browser performs a full-load navigation instead of the link being dead. In development it also warns once per destination:

[Pyreon] <RouterLink to="/about"> rendered without a RouterProvider — falling back
to a plain anchor (full page load on click). Wrap the tree in <RouterProvider router={…}>.

Full-reload warning for plain <a href> (dev only)

A plain internal <a href="/about"> in a router app triggers a full page reload — almost never what you want. In development, the router registers a document-level click listener (removed by router.destroy()) that warns when a plain internal anchor is about to full-reload:

[Pyreon] internal <a href="/about"> triggers a full page reload — use
<RouterLink to="/about"> for client-side navigation. (Deliberate full-load
link? add target/download, or data-allow-reload.)

<RouterLink> clicks never warn (the link preventDefault()s the internal clicks it handles, which is the discriminator). External URLs, mailto:/tel:, #hash anchors, and modifier/middle clicks are skipped. The warning applies in both history and hash mode — a path-style href full-reloads a hash-mode app too, while valid #/x hrefs are skipped.

For a deliberate full-load link, opt out with any of:

<a href="/legacy-page" data-allow-reload>Old server-rendered page</a>
<a href="/report.pdf" download>Download</a>
<a href="/print-view" target="_blank">Print view</a>

RouterLink inspects to at runtime and only intercepts internal navigations. Anything else is left to the browser, with the right <a> attributes applied automatically:

to shapeKindRendered <a>Click
/about, aboutinternalhref="#/about" (hash) / href="/about" (history)router.push()
https://x.com/…, //cdn.x.com/…externaltarget="_blank" rel="noopener noreferrer"full browser navigation (new tab)
mailto:, tel:, sms:, other schemesprotocolplain <a href>browser handles it
#sectionhashplain <a href="#section">browser scroll
https://this-site.com/about (same origin)internal by defaultstripped to href="#/about"router.push()
<RouterLink to="/docs">Docs</RouterLink>                {/* client nav */}
<RouterLink to="https://github.com/pyreon">GitHub</RouterLink> {/* new tab, secure rel */}
<RouterLink to="mailto:hi@pyreon.dev">Email</RouterLink>  {/* browser mailto */}

Modifier / middle-clicks (⌘/Ctrl/Shift/Alt, non-primary button) always fall through to the browser's native open-in-new-tab behaviour, even on internal links.

Configure globally via createRouter({ links }) (RouterOptions):

createRouter({
  routes,
  links: {
    sameOriginAbsolute: 'internal', // or 'external' — how to treat same-origin absolute URLs (default 'internal')
    externalNewTab: true,           // external links open in a new tab (default true)
    externalRel: 'noopener noreferrer', // rel applied to new-tab links (default)
  },
})

Override per link — explicit props win over config and auto-detection:

<RouterLink to="https://x.com" target="_self">stay here</RouterLink>
<RouterLink to="/report.pdf" external>open PDF in new tab</RouterLink>
<RouterLink to="https://this-site.com/a" external={false}>client-nav a same-origin absolute</RouterLink>

Typed routes

By default to is string — the router is fully usable with no codegen. Register your app's routes by augmenting the RegisteredRoutes interface (a build step like @pyreon/zero's typedRoutes can emit this for you) and to gains autocomplete + typo-rejection, while still accepting dynamic strings and external URLs:

declare module '@pyreon/router' {
  interface RegisteredRoutes {
    '/': Record<string, never>
    '/resume': Record<string, never>
    '/user/:id': { id: string }
  }
}

<RouterLink to="/resume" />          // ✓ autocompleted
<RouterLink to="/user/42" />         // ✓ concrete path matches the `:id` pattern
<RouterLink to="/rezume" />          // ✗ TS error: did you mean '/resume'?
<RouterLink to="/users/42" />        // ✗ TS error: wrong prefix (it's /user/:id)
<RouterLink to={someString} />       // ✓ dynamic string, no cast
<RouterLink to="https://x.com" />    // ✓ external URL, never typo-checked

Concrete paths validate against :param patterns via InterpolateRoute (/user/:id accepts /user/42, /user/anything, but not /users/42).

The generic RouterLink<const T> infers T from the to literal and runs it through CheckHref<T>: a literal that looks internal but isn't registered collapses to the route union (producing the error), while registered routes / external URLs / dynamic strings pass through unchanged. This is a strict superset of to: string — nothing that compiled before stops compiling.

Prefetch Strategies

Prefetching runs the target route's loader in advance so data is ready the moment the user navigates. This is on by default — every <RouterLink> prefetches on hover and keyboard focus unless you opt out. You do not need to do anything to get instant-feeling navigation; the table below is for tuning, not enabling.

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 — and reports what it did:

push() and replace() return promises that resolve after all guards, loaders, and the navigation commit have completed — with a NavigationResult telling you what happened:

async function handleLogin() {
  await authenticate()
  const result = await router.push('/dashboard')
  // 'committed'  — the route (or a redirect it chained into) is now current
  // 'cancelled'  — a blocker / guard / middleware refused the navigation
  // 'superseded' — a newer navigation started while this one was in flight
  if (result !== 'committed') showToast('Navigation was blocked')
}

Callers that ignore the value keep working — await router.push(...) still settles exactly when the navigation settles.

Security:

The router blocks javascript: and data: URIs in navigation targets. Attempting to navigate to such a URI logs a warning and redirects to /.

router.push('javascript:alert(1)') // blocked, navigates to /
router.push('data:text/html,...') // blocked, navigates to /

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.

Accessibility — route announcements

In a single-page app, navigation swaps content without a full page load, so screen readers are never told the "page" changed — a click on a link can appear to do nothing to a screen-reader user. The router closes this gap automatically: on every navigation, the root <RouterView> writes the new page's name into a visually-hidden aria-live="polite" region, so assistive tech announces it (the same pattern Next.js, Remix and gov.uk ship).

  • Zero config — it's on by default; nothing to mount or wire.

  • What's announced — the new document.title (which @pyreon/head has updated by the time navigation commits), falling back to the pathname when there's no title.

  • When — only on genuine path changes. The initial page load isn't announced (the screen reader already reads the freshly-loaded page), and same-path query/hash changes are ignored.

  • Only the root view announces — nested <RouterView>s (layouts) don't double-announce.

Opt out (e.g. if you run your own announcer) on the root view:

<RouterView announceRouteChanges={false} />

For focus management on route change (moving keyboard focus to the new content), place your own focus target — a framework-level default is a separate, opt-in concern so it never fights an app that manages focus itself.

View Transitions API

Route navigations are automatically wrapped in document.startViewTransition() when the browser supports the View Transitions API. This provides smooth CSS-driven transitions between pages with zero configuration.

Reduced motion is respected automatically. When the user's OS is set to reduce motion (@media (prefers-reduced-motion: reduce)), the router skips the View Transition and swaps the DOM synchronously instead — the navigation still happens, only the fade/slide animation is suppressed. This satisfies WCAG 2.3.3 "Animation from Interactions" with no configuration. The preference is read per-navigation, so toggling it mid-session takes effect on the next route change.

To opt out for a specific route, set meta.viewTransition: false:

{
  path: '/modal',
  component: ModalPage,
  meta: { viewTransition: false },
}

The ::view-transition-old(root) and ::view-transition-new(root) CSS pseudo-elements can be styled for custom transition effects:

::view-transition-old(root) {
  animation: fade-out 200ms ease-in;
}
::view-transition-new(root) {
  animation: fade-in 300ms ease-out;
}

Hash Scrolling

When navigating to a URL with a hash fragment (e.g., /docs#installation), the router automatically scrolls to the element with the matching id. This works for both initial page load and client-side navigation.

Route Error Boundaries

The errorComponent on a route record catches render errors (not just loader errors). When a route component throws during rendering, the error component is shown instead:

{
  path: '/dashboard',
  component: Dashboard,
  errorComponent: (props) => (
    <div>
      <h2>Dashboard Error</h2>
      <p>{props.error.message}</p>
      <button onClick={props.reset}>Retry</button>
    </div>
  ),
}

Middleware Chain

Routes can define middleware that runs before guards and loaders. Middleware receives a context object with a data property for passing data downstream:

import type { RouteMiddleware } from '@pyreon/router'

const authMiddleware: RouteMiddleware = async (ctx) => {
  const user = await getUser(ctx.request)
  ctx.data.user = user
  if (!user) return '/login' // redirect
}

const routes = [
  {
    path: '/admin',
    component: AdminLayout,
    middleware: [authMiddleware],
    children: [
      { path: 'dashboard', component: AdminDashboard },
    ],
  },
]

Inside components, read middleware data with useMiddlewareData():

import { useMiddlewareData } from '@pyreon/router'

function AdminDashboard() {
  const data = useMiddlewareData() // () => Record<string, unknown>
  const user = () => data().user as User
  return <h1>Welcome, {user().name}</h1>
}

useMiddlewareData() returns a reactive accessor () => Record<string, unknown> — call it to read the accumulated middleware data, and cast individual keys at the use site. It is not generic and does not return the object directly. The data is per-navigation: it resets to {} when a navigation whose matched chain has no middleware commits.

Typed Route Names

Router<TNames> accepts a generic for typed named navigation:

type RouteNames = 'home' | 'user' | 'settings'

const router = createRouter<RouteNames>({
  routes: [
    { path: '/', component: Home, name: 'home' },
    { path: '/user/:id', component: User, name: 'user' },
    { path: '/settings', component: Settings, name: 'settings' },
  ],
})

// Typed — only 'home' | 'user' | 'settings' allowed:
router.push({ name: 'user', params: { id: '42' } })
// router.push({ name: 'invalid' })  // TypeScript 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