@pyreon/router — API Reference
Generated from
router'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see router.
Type-safe client-side router for Pyreon with nested routes, per-route and global navigation guards, data loaders, middleware chain, View Transitions API integration, and typed search params. Context-based (RouterContext) with hash and history mode support. Route params are inferred from path strings ("/user/:id" yields { id: string }). Named routes enable typed programmatic navigation. SSR-compatible with server-side route resolution. Hash mode uses history.pushState (not window.location.hash) to avoid double-update. await router.push() resolves after the View Transition updateCallbackDone (DOM commit), not after animation completion.
Features
Server loaders (zero integration): records carry
serverLoader(SSR module graph only) /hasServerLoader(client marker) — client navigations fetch the whole chain via ONE request to thedataEndpoint(default<base>/_pyreon/data); redirect() from a server loader arrives as a JSON envelope the client turns into a navigationcreateRouter() — factory with routes, guards, middleware, loaders, hash/history mode
RouterProvider / RouterView / RouterLink — context-based rendering components
useRouter / useRoute — programmatic navigation and typed route access
useIsActive — reactive boolean for path matching (segment-aware prefix)
useTypedSearchParams — typed search params with auto-coercion
useTransition — reactive signal for route transition state
useMiddlewareData — read data set by route middleware chain
useLoaderData — access route loader results
View Transitions API — auto-enabled, awaits updateCallbackDone
Named routes — typed navigation via { name, params }
Nested routes — recursive matching with child RouterView
Navigation guards — per-route and global beforeEnter/afterEach hooks
Complete example
A full, end-to-end usage of the package:
import { createRouter, RouterProvider, RouterView, RouterLink, useRouter, useRoute, useIsActive, useTypedSearchParams, useTransition, useLoaderData, useMiddlewareData } from "@pyreon/router"
import { mount } from "@pyreon/runtime-dom"
// Define routes with typed params, guards, loaders, and middleware
const router = createRouter({
routes: [
{ path: "/", component: Home, name: "home" },
{ path: "/user/:id", component: User, name: "user",
loader: ({ params }) => fetchUser(params.id),
meta: { title: "User Profile" } },
{ path: "/admin", component: AdminLayout,
beforeEnter: (to, from) => isAdmin() || "/login",
middleware: [authMiddleware, loggerMiddleware], // per-route middleware — runs before guards
children: [
{ path: "users", component: AdminUsers },
{ path: "settings", component: AdminSettings },
] },
{ path: "/settings", redirect: "/admin/settings", component: AdminSettings },
{ path: "(.*)", component: NotFound },
],
})
// Mount with RouterProvider
mount(
<RouterProvider router={router}>
<nav>
<RouterLink to="/" activeClass="nav-active">Home</RouterLink>
<RouterLink to="/user/42">Profile</RouterLink>
</nav>
<RouterView />
</RouterProvider>,
document.getElementById("app")!
)
// Inside a component — hooks
const User = () => {
const route = useRoute<"/user/:id">()
const data = useLoaderData<UserData>()
const router = useRouter()
const isAdmin = useIsActive("/admin")
const isTransitioning = useTransition()
const [search, setSearch] = useTypedSearchParams({ tab: "string", page: "number" })
return (
<div>
<h1>{data.name} (ID: {route().params.id})</h1>
<Show when={isTransitioning()}>
<ProgressBar />
</Show>
<button onClick={() => router.push("/")}>Go Home</button>
</div>
)
}Exports
| Symbol | Kind | Summary |
|---|---|---|
runServerLoaders | function | The single-fetch data endpoint's worker (server-only — serverLoader functions exist only in the SSR module graph). |
createRouter | function | Create a router instance with route records, guards, middleware, and mode configuration. |
RouterProvider | component | Provide the router instance to the component tree via RouterContext. |
RouterView | component | Render the matched route's component. |
RouterLink | component | Declarative navigation link that renders an <a> element. |
useRouter | hook | Access the router instance for programmatic navigation. |
useRoute | hook | Access the current resolved route as a reactive accessor. |
useIsActive | hook | Returns a reactive boolean for whether a path matches the current route. |
useTypedSearchParams | hook | Type-safe search params with auto-coercion from URL strings. |
useTransition | hook | Returns a reactive accessor for route transition state. |
useMiddlewareData | hook | Returns a reactive accessor for data set by RouteMiddleware in the middleware chain. |
useLoaderData | hook | Access the data returned by the current route's loader function. |
redirect | function | Throw inside a route loader to redirect the navigation BEFORE the layout renders. |
isRedirectError | function | Type guard for errors thrown by redirect(). |
getRedirectInfo | function | Extract the redirect URL and status from a thrown RedirectError. |
useSearchParams | hook | Access and update URL search params as a reactive tuple. |
useBlocker | hook | Block navigations while a condition holds. |
onBeforeRouteLeave | function | Register a per-component navigation guard that fires when leaving the current route. |
onBeforeRouteUpdate | function | Register a per-component navigation guard that fires when the route updates but the same component stays mounted (e.g., |
useNavigate | function | Returns an imperative navigate function. |
useParams | function | Returns a SNAPSHOT map of the current route's path params ({ id: '42' } for /user/:id). |
useValidatedSearch | function | Returns a REACTIVE ACCESSOR () => T for the current route's VALIDATED search params. |
notFound / NotFoundBoundary | function | The Next.js-style 404 pair. |
lazy | function | Code-split a route component. |
LoaderData | type | Derive a route loader's RESOLVED data type from the loader function itself — pair with `useLoaderData<LoaderData<typeof |
API
runServerLoaders function
router.runServerLoaders(path: string, request?: Request): Promise<{ kind: 'data'; data: Record<number, unknown> } | { kind: 'redirect'; to: string; status: number }>The single-fetch data endpoint's worker (server-only — serverLoader functions exist only in the SSR module graph). Runs ONLY the matched chain's serverLoader records (NOT isomorphic loaders — those run client-side; running them here would double-fire side effects) and keys results by MATCHED-CHAIN INDEX (a layout and its index page share a path, so path-keying collided). A redirect() thrown by any server loader returns the redirect descriptor instead of data.
Example
// zero's /_pyreon/data endpoint does exactly this:
const result = await router.runServerLoaders('/dash', ctx.req)
if (result.kind === 'redirect') return jsonRedirect(result)
return json({ data: result.data }) // keyed by matched-chain indexCommon mistakes
Calling it client-side —
serverLoaderis undefined in the client graph; the client router does the single FETCH insteadExpecting isomorphic
loaders to run here — deliberately excluded (double-fire prevention); they run on the client during navigation
createRouter function
createRouter(options: RouterOptions | RouteRecord[]): RouterCreate a router instance with route records, guards, middleware, and mode configuration. Accepts either an array of route records (shorthand) or a full RouterOptions object with routes, mode ("history" | "hash"), scrollBehavior, beforeEach, afterEach, and middleware. The returned Router is generic over route names for typed programmatic navigation.
Example
const router = createRouter([
{ path: "/", component: Home },
{ path: "/user/:id", component: User, loader: ({ params }) => fetchUser(params.id) },
{ path: "/admin", component: Admin, beforeEnter: requireAuth, children: [
{ path: "settings", component: Settings },
]},
])Common mistakes
createRouter({ routes: [...], mode: "hash" })and usingwindow.location.hashelsewhere — hash mode useshistory.pushState, notlocation.hash. Readinglocation.hashdirectly will not reflect router stateDefining route paths without leading
/in root routes — all root-level paths must start with/Using
redirect: "/target"with a guard on the same route — redirects bypass guards. UsebeforeEnterto conditionally redirect insteadForgetting the catch-all route —
{ path: "(.*)", component: NotFound }should be the last route to handle 404s
See also: RouterProvider · useRouter · useRoute
RouterProvider component
<RouterProvider router={router}>{children}</RouterProvider>Provide the router instance to the component tree via RouterContext. Must wrap the entire app (or the routed section). Sets up the context stack so useRouter(), useRoute(), and other hooks can access the router.
Example
const App = () => (
<RouterProvider router={router}>
<nav><RouterLink to="/">Home</RouterLink></nav>
<RouterView />
</RouterProvider>
)See also: createRouter · RouterView · RouterLink
RouterView component
<RouterView />Render the matched route's component. For nested routes, the parent route component includes a <RouterView /> that renders the matched child. Each <RouterView> renders one level of the route tree.
Example
// Renders the matched route's component
<RouterView />
// Nested routes: parent component includes <RouterView /> for children
const Admin = () => (
<div>
<h1>Admin</h1>
<RouterView /> {/* renders Settings, Users, etc. */}
</div>
)Common mistakes
SSR renders a BLANK page for a lazy route when the handler only ran
prefetchLoaderData— that runs LOADERS ONLY, it does NOT resolvelazy()route components.renderToStringis synchronous, so an unresolved lazy component falls back to its empty loading state. The SSR handler must ALSO callrouter.preload(path, req)(resolves lazy components into_componentCache) before rendering.Forgetting the inner
<RouterView />inside a LAYOUT component — nested child routes render by placing a SECOND<RouterView />in the layout body, one per depth level. Without it the layout renders but its children never appear (they have no mount point).Expecting a param-only navigation (
/user/1 → /user/2) to re-run the layout body — it does NOT. Each depth is a single atomiccomputedkeyed on (matched record, component, its own loader data, route ref); it re-emits only when the matched RECORD or that depth's own loader data changes. A loader-LESS layout mounts ONCE and persists; only the page leaf re-renders (via reactive props). Do not put per-navigation side effects in a layout body expecting them to re-fire.Passing the route component as a prop or child to
<RouterView>— it takes none exceptrouter?(explicit router override; defaults to the context/active router) andannounceRouteChanges(a11y live-region opt-out). It reads the matched chain fromRouterContext; configure routes increateRouter, never on RouterView.In a
*-compatapp, wrapping<RouterView>in your OWN layout helper that usesprovide()/onMount()/effect()at body scope without marking itnativeCompat()— the compat jsx runtime relocates its setup into a wrapper accessor. RouterView itself shipsnativeCompat-marked; your helpers around it must be too.Passing
layoutto@pyreon/zero'screateApp/startClientwhen fs-router already emits_layout.tsx— the layout is a parent route in the matched chain that RouterView renders, so the explicitlayoutmounts it a SECOND time (two navbars / two providers). Let_layout.tsxbe the canonical registration; do not also passlayout.
See also: RouterProvider · createRouter
RouterLink component
<RouterLink to={path} activeClass={cls} exactActiveClass={cls}>{children}</RouterLink>Declarative navigation link that renders an <a> element. Applies activeClass when the current route matches the link path (prefix), and exactActiveClass for exact matches. Only INTERNAL navigations are intercepted (router.push() + preventDefault); external URLs, mailto:/tel:, and #hash are detected from to at runtime and left to the browser — external links auto-get target="_blank" rel="noopener noreferrer". Resolves its router like every hook does (context ?? active router), so links outside the provider subtree still client-navigate; with NO router resolvable it degrades to a plain anchor (plain-path href, no click interception → full-load navigation) and warns once per to in dev. to is generic (CheckHref<T>): with routes registered via the RegisteredRoutes augmentation, a mistyped internal path is a compile error, while dynamic strings and external URLs are always accepted.
Example
<RouterLink to="/" activeClass="nav-active">Home</RouterLink>
<RouterLink to="https://github.com/pyreon">GitHub</RouterLink>{/* auto target=_blank + secure rel */}Common mistakes
<a href="/about" onClick={() => router.push("/about")}>— use<RouterLink to="/about">instead; it handles the anchor element, active class, and click interceptionPlain internal
<a href="/about">in a router app — triggers a FULL page reload (dev warns at the document level with the RouterLink replacement). Deliberate full-load links opt out viatarget,download, ordata-allow-reload.Rendering
<RouterLink>with no<RouterProvider>ancestor (and nosetActiveRouter) — the link degrades to a plain anchor: plain-pathhref, full page load on click (dev warns once perto). Wrap the tree in<RouterProvider router={…}>for client-side navigation.Wrapping an external URL in a plain
<a>to avoid router interception — unnecessary:<RouterLink to="https://x.com">already detects it as external, renderstarget="_blank" rel="noopener noreferrer", and does NOT client-navigate. Override withexternal/target/relprops or thecreateRouter({ links })config.<RouterLink to={dynamicPath}>without calling the signal — must call:<RouterLink to={dynamicPath()}>(or let the compiler handle it via_rp())
See also: useRouter · useIsActive · createRouter
useRouter hook
useRouter(): RouterAccess the router instance for programmatic navigation. Returns the Router object with push(), replace(), back(), forward(), go(). await router.push() resolves after the View Transition updateCallbackDone (DOM commit is complete, new route state is live), NOT after the animation finishes — and resolves WITH a NavigationResult: 'committed' (route changed), 'cancelled' (a blocker/guard/middleware refused), or 'superseded' (a newer navigation won). Browser Back/Forward routes through the same pipeline (guards, blockers, loaders, afterEach, scroll, meta.title).
Example
const router = useRouter()
router.push("/settings")
router.push({ name: "user", params: { id: "42" } })
router.replace("/login")
router.back()
router.forward()
router.go(-2)Common mistakes
router.push("/path")at the top level of a component body — this is synchronous imperative navigation during render, causing an infinite loop. Wrap inonMount, event handler, oreffectawait router.push("/path")expecting animation completion —pushresolves after DOM commit (updateCallbackDone), not after View Transition animation finishes. Use the returned transition object's.finishedif you need to wait for animationCalling
useRouter()outside a<RouterProvider>— throws because no router context exists
See also: useRoute · RouterLink · createRouter
useRoute hook
useRoute<TPath extends string>(): () => ResolvedRoute<ExtractParams<TPath>>Access the current resolved route as a reactive accessor. Generic over the path string for typed params — useRoute<"/user/:id">() yields route().params.id: string. Returns a function (accessor) that must be called to read the current route — reads inside reactive scopes track route changes.
Example
// Type-safe params:
const route = useRoute<"/user/:id">()
const userId = route().params.id // string
// Access query, meta, etc:
route().query
route().metaCommon mistakes
const { params } = useRoute()—useRoute()returns an ACCESSOR (it ISrouter.currentRoute), so this destructures the FUNCTION object (which has noparams) →undefined. Read it:const route = useRoute(); route().params.id.Reading
route().params.idOUTSIDE a reactive scope (in the raw component body) captures the value ONCE — it will NOT update on a same-component param change (/user/1 → /user/2re-renders the User leaf but the top-levelconstwas already evaluated). Read inside JSX /effect/computedto track.Treating the
<TPath>type param as validated —useRoute<"/user/:id">()is your ASSERTION about the mounted path (the impl castsas never). A wrong literal gives wrong param types with no runtime error.Calling
useRoute()with no<RouterProvider>ancestor and no active router — throws[Pyreon] No router installed.
See also: useRouter · useSearchParams · useLoaderData
useIsActive hook
useIsActive(path: string, exact?: boolean): () => booleanReturns a reactive boolean for whether a path matches the current route. Segment-aware prefix matching: /admin matches /admin/users but NOT /admin-panel. Pass exact=true for exact-only matching. Updates reactively when the route changes.
Example
const isHome = useIsActive("/")
const isAdmin = useIsActive("/admin") // prefix match
const isExactAdmin = useIsActive("/admin", true) // exact only
// Reactive — updates when route changes:
<a class={{ active: isAdmin() }} href="/admin">Admin</a>Common mistakes
useIsActive("/admin")matching/admin-panel— this does NOT happen. Matching is segment-aware:/adminonly matches paths starting with/admin/or exactly/adminif (useIsActive("/settings")())at component top level — the outer call returns an accessor; make sure to read it inside a reactive scope for updatesUsing
useIsActivefor complex route matching — it only does path prefix/exact matching. For query-param-aware or meta-aware checks, useuseRoute()directly
See also: useRoute · RouterLink
useTypedSearchParams hook
useTypedSearchParams<T extends SearchParamSchema>(schema: T): [get: () => InferSearchParams<T>, set: (updates: Partial<InferSearchParams<T>>) => Promise<NavigationResult>]Type-safe search params with auto-coercion from URL strings. Schema keys define parameter names, values define types ("string", "number", "boolean"). Returns a [get, set] TUPLE (like useSearchParams): get() reads the coerced values reactively; set() merges updates and navigates via router.replace (resolving with the navigation's NavigationResult). Missing numbers coerce to 0, booleans accept "true"/"1".
Example
const [params, setParams] = useTypedSearchParams({ page: "number", q: "string", active: "boolean" })
params().page // number (auto-coerced)
params().q // string
setParams({ page: 2 }) // updates URL via router.replaceCommon mistakes
Destructuring an object (
params.page()) — the hook returns a TUPLE:const [params, setParams] = useTypedSearchParams(...), read viaparams().pageExpecting
set()to bypass navigation — it navigates viarouter.replace, so guards/blockers apply (check the resolvedNavigationResultif you need to know it committed)
See also: useSearchParams · useRoute
useTransition hook
useTransition(): () => booleanReturns a reactive accessor for route transition state. The accessor is true during navigation (while guards run + loaders resolve), false when the new route is mounted. Call it inside a reactive scope. Useful for progress bars and global loading indicators.
Example
const isTransitioning = useTransition()
<Show when={isTransitioning()}>
<ProgressBar />
</Show>See also: useRouter · useRoute
useMiddlewareData hook
useMiddlewareData(): () => Record<string, unknown>Returns a reactive accessor for data set by RouteMiddleware in the middleware chain. Middleware functions receive ctx with a mutable ctx.data object — properties set there are read by calling the returned accessor inside a reactive scope. The data is per-navigation: it resets to {} when a navigation whose chain has no middleware commits. (Stored on the router at commit time — the in-flight route object never becomes currentRoute(), which is why the pre-fix accessor always returned {}.)
Example
// Middleware:
const authMiddleware: RouteMiddleware = async (ctx) => {
ctx.data.user = await getUser(ctx.to)
}
// Component:
const data = useMiddlewareData()
// data().user is availableSee also: createRouter · useLoaderData
useLoaderData hook
useLoaderData<T>(): TAccess the data returned by the current route's loader function. The loader runs before the route component mounts; its return value is cached and available synchronously via this hook. Generic over the loader return type.
Example
// Route: { path: "/user/:id", component: User, loader: ({ params }) => fetchUser(params.id) }
const User = () => {
const data = useLoaderData<UserData>()
return <div>{data.name}</div>
}Common mistakes
Getting
undefinedwhen the route has NOloader— it readsLoaderDataContext(defaultundefined). The<T>generic is an unchecked CAST, not validation;data.namethen throws on the undefined.Wrapping
useLoaderData()in aneffectexpecting it to re-fire on navigation — it is a plain (non-reactive) context READ, a snapshot at mount, NOT an accessor. RouterView RE-MOUNTS the route component on a real navigation, which re-runs the body and re-reads the hook; there is no signal to subscribe to.SSR returning
undefinedat render time — loaders are async butrenderToStringis synchronous, so the handler must run loaders (prefetchLoaderDataorrouter.preload) BEFORE rendering. An un-prefetched loader has not resolved when the component reads the hook.Calling it in a component NOT rendered by
<RouterView>(a sibling inside<RouterProvider>, a portal outside the route tree) — noLoaderDataProviderwraps it, so the context is the defaultundefined.Expecting a nested LAYOUT's
useLoaderData()to return the child PAGE's data — each depth is wrapped with its OWNLoaderDataProvider, so a layout reads its own loader's data and the page reads the page's. The hook reads the nearest provider, not the leaf.
See also: useMiddlewareData · useRoute
redirect function
redirect(url: string, status?: 301 | 302 | 303 | 307 | 308): neverThrow inside a route loader to redirect the navigation BEFORE the layout renders. On SSR (initial nav), the thrown error is converted by @pyreon/server's handler into a real HTTP 302/307 Location: response — no layout HTML leaves the server. On CSR (subsequent nav), the redirect propagates through the navigate flow and triggers router.replace() before any matched route's component mounts. Replaces the fragile onMount + router.push() workaround for auth-gates under nested-layout dev SSR + hydration. Default status is 307 (Temporary Redirect, method-preserving).
Example
// src/routes/app/_layout.tsx
import { redirect, type LoaderContext } from "@pyreon/router"
export async function loader(ctx: LoaderContext) {
// SSR: read from request headers; CSR: read from document.cookie
const cookie = ctx.request?.headers.get("cookie")
?? (typeof document !== "undefined" ? document.cookie : "")
const sid = /(?:^|;\s*)sid=([^;]+)/.exec(cookie)?.[1]
if (!sid) redirect("/login")
const session = await getSession(sid)
if (!session) redirect("/login")
return { session }
}Common mistakes
Calling
redirect()outside a loader (in a component body, an event handler, etc.) — the helper expects to be caught by the loader-runner. For imperative redirects from event handlers, userouter.replace(target)instead.Forgetting to make
LoaderContext.requestaccess optional. It's populated only on SSR; CSR loaders seerequest: undefined. Read both:ctx.request?.headers.get('cookie') ?? document.cookie.Using
redirect()for control-flow that should be a<Match>/<Show>conditional — the helper is for redirecting the URL, not for branching the rendered output.Returning
redirect()instead of throwing it. The helper has return typeneverand throws —return redirect(...)is misleading and may suppress the throw under TS strict-null checks.Picking the wrong status. Default
307preserves the request method (POST stays POST after redirect). Use302/303to force GET on the target. Use301/308for PERMANENT moves (browsers cache them aggressively).Assuming
redirect()cancels every loader in a sibling chain. The first loader to throw wins; later loaders in the samePromise.allSettledbatch may have already started executing before the redirect short-circuits. Treat them as best-effort.
See also: notFound · useLoaderData · isRedirectError
isRedirectError function
isRedirectError(err: unknown): booleanType guard for errors thrown by redirect(). Used internally by the router (CSR) and @pyreon/server (SSR) to distinguish redirect-control-flow errors from real failures. Useful in custom error boundaries that should let redirects pass through to the framework instead of catching them.
Example
import { ErrorBoundary } from "@pyreon/core"
import { isRedirectError } from "@pyreon/router"
<ErrorBoundary fallback={(err, reset) => {
if (isRedirectError(err)) throw err // let the framework handle it
return <ErrorPage error={err} onReset={reset} />
}}>
<App />
</ErrorBoundary>See also: redirect · isNotFoundError · getRedirectInfo
getRedirectInfo function
getRedirectInfo(err: unknown): { url: string; status: 301 | 302 | 303 | 307 | 308 } | nullExtract the redirect URL and status from a thrown RedirectError. Returns null for non-redirect errors. Used by @pyreon/server's SSR handler to convert the thrown error into a 302/307 Response.
Example
import { getRedirectInfo } from "@pyreon/router"
try {
await prefetchLoaderData(router, path, request)
} catch (err) {
const info = getRedirectInfo(err)
if (info) return new Response(null, { status: info.status, headers: { Location: info.url } })
throw err
}See also: redirect · isRedirectError
useSearchParams hook
useSearchParams<T>(defaults?: T): [get: () => T, set: (updates: Partial<T>) => Promise<NavigationResult>]Access and update URL search params as a reactive tuple. Returns [get, set] where get() reads the current params and set() updates them via replaceState. For typed params with auto-coercion, prefer useTypedSearchParams.
Example
const [search, setSearch] = useSearchParams({ page: "1", sort: "name" })
// Read:
search().page // "1"
// Write:
setSearch({ page: "2" })Common mistakes
Object-destructuring the return (
params.page()) — it is a[get, set]TUPLE likeuseTypedSearchParams:const [search, setSearch] = useSearchParams(...), read viasearch().page.Expecting auto-coerced types —
useSearchParamsvalues are RAW strings (search().pageis"1", not1). For typed, auto-coerced params ("number"/"boolean"), useuseTypedSearchParams.
See also: useTypedSearchParams · useRoute
useBlocker hook
useBlocker(fn: BlockerFn): BlockerBlock navigations while a condition holds. fn(to, from) is called before EVERY navigation — including browser Back/Forward, which route through the full pipeline — and returning true (or resolving to true; async blockers are supported, e.g. confirm() dialogs) cancels it. A cancelled browser traversal restores the URL/history position. Returns { remove() } to unregister (auto-removed on component unmount). Also installs a shared ref-counted beforeunload handler so tab-close shows the browser confirmation while any blocker is active.
Example
const blocker = useBlocker((to, from) => {
return form.isDirty() && !confirm('Discard unsaved changes?')
})
// later, e.g. after save:
blocker.remove()Common mistakes
Expecting
proceed()/reset()methods (React Router shape) — Pyreon's blocker is a predicate: returntrueto block,falseto allow;remove()unregisters itReturning
falseto block — inverted:truemeans BLOCK (it answers "should this navigation be blocked?")Assuming the Back button bypasses blockers — browser traversals run the same pipeline; a blocked Back restores the URL
See also: useRouter · onBeforeRouteLeave
onBeforeRouteLeave function
onBeforeRouteLeave(guard: NavigationGuard): () => voidRegister a per-component navigation guard that fires when leaving the current route. Return false to cancel, a string path to redirect, or undefined to allow. Must be called during component setup.
Example
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges()) return false // cancel navigation
})Common mistakes
Return-value inversion vs
useBlocker— a GUARD returnsfalseto CANCEL (andundefinedto allow), while a BLOCKER returnstrueto BLOCK. They are opposite; mixing them up either lets navigations through or blocks them all.Calling it in an event handler or
effect— the guard DOES register (it goes throughrouter.beforeEachunconditionally), but only theonUnmountauto-removal is setup-bound: outside component setup the cleanup never attaches, so the guard LEAKS (keeps firing after the component unmounts). Call it during setup, or hold the returned remover and call it yourself.Returning a truthy non-string (e.g. an object) expecting a redirect — only a STRING return redirects (to that path);
falsecancels,undefinedallows.
See also: onBeforeRouteUpdate · useBlocker
onBeforeRouteUpdate function
onBeforeRouteUpdate(guard: NavigationGuard): () => voidRegister a per-component navigation guard that fires when the route updates but the same component stays mounted (e.g., param change /user/1 to /user/2). Same return semantics as onBeforeRouteLeave — return false to cancel, a string path to redirect, or undefined to allow.
Example
onBeforeRouteUpdate((to, from) => {
if (to.params.id === from.params.id) return // no change — allow
if (hasUnsavedChanges()) return false // cancel: the param change would lose unsaved edits
// otherwise allow — reload data for the new ID
})See also: onBeforeRouteLeave · useRoute
useNavigate function
useNavigate() => (path: string) => voidReturns an imperative navigate function. Resolves the active router (context first, module singleton fallback) and returns (path) => router.push(path). The returned function ALWAYS pushes (never replaces) and is typed void — the underlying push() Promise<NavigationResult> is dropped. Mirrors the useNavigate() shape on the native (Swift/Kotlin) targets for cross-target source parity.
Example
const navigate = useNavigate()
// call it from an event handler / onMount / effect — never the render body:
const goHome = () => navigate('/')Common mistakes
Awaiting the result — the returned function is typed
void(it dropspush()'s Promise). To observe the NavigationResult ('committed' | 'cancelled' | 'superseded'), or toreplace()instead of push, calluseRouter().push()/.replace()directly.Calling
navigate()synchronously in the component body — that fires during render and infinite-loops. Defer it (event handler,onMount,effect); thepyreon/no-imperative-navigate-in-renderlint rule catches this.Calling
useNavigate()before a router exists (outside a<RouterProvider>with no module router set) — it throws[Pyreon] No router installed.
See also: useRouter · RouterLink
useParams function
useParams<T extends Record<string, string> = Record<string, string>>() => TReturns a SNAPSHOT map of the current route's path params ({ id: '42' } for /user/:id). Values are always STRINGS at runtime — the generic T is caller-supplied typing only, it does NOT coerce. It reads router.currentRoute().params once at call time, so the snapshot is captured when the hook runs; to track param changes across navigation, read useRoute()().params in a reactive scope instead.
Example
const params = useParams<{ id: string }>() // snapshot at setup
// for params that update on navigation, use the reactive route accessor:
const route = useRoute<'/user/:id'>()
// route().params.id re-reads on every navigationCommon mistakes
Treating the returned object as LIVE — it is a one-time snapshot from the component body. On a
/user/1->/user/2navigation (same component stays mounted), the captured object does NOT update. ReaduseRoute()().paramsin a reactive scope to track changes.Assuming param values are typed — at runtime they are always strings;
useParams<{ id: number }>()types but does NOT coerce. Parse yourself (Number(params.id)).
See also: useRoute · useNavigate
useValidatedSearch function
useValidatedSearch<T extends Record<string, unknown> = Record<string, unknown>>() => () => TReturns a REACTIVE ACCESSOR () => T for the current route's VALIDATED search params. It takes NO argument — validation is configured on the ROUTE record via validateSearch(raw) (run during navigation; supports arbitrary validators like Zod / Valibot), and this hook surfaces its result off currentRoute().search (an empty {} when no validateSearch is set). Structural sharing (shallow-equal caching) means unrelated query-param changes do not re-trigger downstream reads.
Example
// route config: { path: '/search', validateSearch: (raw) => schema.parse(raw) }
const search = useValidatedSearch<{ q: string; page: number }>()
// read the accessor inside a reactive scope:
const page = () => search().pageCommon mistakes
Passing a schema to the hook — it takes NO argument. The schema goes on the route record's
validateSearchconfig;useValidatedSearch()only READS the already-validated result.Confusing it with the other two search hooks:
useTypedSearchParams(schema)takes a{ key: 'string' | 'number' | 'boolean' }shape, coerces primitives itself, and returns a[get, set]tuple;useSearchParams(defaults?)returns raw strings with no coercion.useValidatedSearchis READ-ONLY (no setter), argument-less, and defers to the route validator (arbitrary shapes).Reading the accessor once and caching the value — call
search()inside a reactive scope so it re-reads on navigation.
See also: useTypedSearchParams · useSearchParams
notFound / NotFoundBoundary function
notFound(message?: string) => never · NotFoundBoundary(props: { fallback: ComponentFn | VNodeChild; children?: VNodeChild }) => VNodeChildThe Next.js-style 404 pair. notFound() THROWS a branded Error (message defaults to 'Not Found') from inside a loader or component; isNotFoundError detects the brand (Symbol.for('pyreon.notFound'), realm-shared). NotFoundBoundary wraps the core ErrorBoundary: it renders children normally, and when a notFound() is thrown in its subtree it renders fallback (invoked as a component with { error, reset } when its arity is <= 1, else returned as a plain VNodeChild). It RE-THROWS any non-notFound error so real errors still propagate to an outer error boundary.
Example
async function loadUser(params: { id: string }) {
const user = await fetchUser(params.id)
if (!user) notFound() // throws — code below is unreachable
return user
}
const app = (
<NotFoundBoundary fallback={() => <h1>404 — not found</h1>}>
<RouterView />
</NotFoundBoundary>
)Common mistakes
Expecting code after
notFound()to run — it THROWS (=> never) and never returns, so a guard-then-continue pattern does not work (everything after it is unreachable).Using
NotFoundBoundaryas a general error boundary — it ONLY handlesnotFound()throws; every other error is RE-THROWN to the nearest outerErrorBoundary.Confusing
notFound()with the route-recordnotFoundComponent/ fs-router_404.tsx— those are the route-level no-match fallback;notFound()drives theNotFoundBoundaryin the rendered subtree (a different mechanism).
See also: redirect · RouterView
lazy function
lazy(loader: () => Promise<ComponentFn | { default: ComponentFn }>, options?: { loading?: ComponentFn; error?: ComponentFn; hmrId?: string }) => LazyComponentCode-split a route component. Returns a LazyComponent DESCRIPTOR (a branded object, NOT a rendered component) to assign as a route component. The loader may resolve a bare component OR an ES-module { default } namespace (so () => import('./Page') works). options.loading / options.error supply Suspense fallbacks; options.hmrId is the dev-only module id fs-router emits for hot-swap. The ROUTER caches resolved chunks in a bounded LRU (maxCacheSize, default 100); lazy() itself does not dedupe.
Example
const routes = [
{ path: '/reports', component: lazy(() => import('./Reports')) },
]Common mistakes
Calling the result like a component (
lazy(...)()) — it is an inert DESCRIPTOR; assign it to a routecomponentand the router resolves it (into_componentCache) on navigation.Assuming
lazy()dedupes concurrent imports — it does not; the router owns caching (bounded LRU, default 100;router.preload()warms it before SSR renderToString, which is required so a lazy route is not blank on the server).
See also: createRouter · RouterView
LoaderData type
type LoaderData<L> = L extends (...args: never[]) => infer R ? Awaited<R> : neverDerive a route loader's RESOLVED data type from the loader function itself — pair with useLoaderData<LoaderData<typeof loader>>() so the component's data type follows the loader with no second annotation (and no drift when the loader changes). Unwraps async returns via Awaited; sync-returning loaders pass through. Type-only, zero runtime bytes.
Example
export const loader = async () => ({ posts: await fetchPosts() })
function PostsPage() {
const data = useLoaderData<LoaderData<typeof loader>>()
return <ul>{/* data.posts is fully typed */}</ul>
}Common mistakes
Annotating
useLoaderData<{ posts: Post[] }>()by hand next to the loader — the drift this type removes; derive it fromtypeof loaderLoaderData<ReturnType<typeof loader>>— pass the FUNCTION type (typeof loader), not its return typeIt types, it does not validate — the loader data crosses an SSR JSON boundary; Date/Map/class instances arrive as plain JSON on the client
See also: useLoaderData · RouteLoaderFn · ExtractParams
Package-level notes
View Transitions — what push() awaits:
await router.push()resolves afterupdateCallbackDone(DOM commit), NOT after animation finishes. It does NOT wait for.finished(~200-300ms)..readyand.finishedget empty.catch()handlers soAbortError: Transition was skippedrejections (from interrupted transitions) do not leak as unhandled promise rejections.
Hash mode uses pushState: Hash mode uses
history.pushState— NOTwindow.location.hashassignment — to avoid double-update from the hashchange event. Readinglocation.hashdirectly will not reflect router state; useuseRoute()instead.
Imperative navigation in render body:
router.push()ornavigate()called synchronously in the component function body causes an infinite render loop. Wrap inonMount, event handlers,effect, or any deferred execution context. Thepyreon/no-imperative-navigate-in-renderlint rule catches this.
Hook ordering with View Transitions:
afterEachhooks and scroll restoration fire AFTER the View Transition callback completes — not before. This means hooks see the NEW route state, which is the correct per-spec behavior but a subtle change from pre-VT versions.
For uses by, not key:
<For>in route lists usesbynotkey.<For each={routes()} key={r => r.path}>silently passes the key to VNode reconciliation instead of the list reconciler. Useby={r => r.path}.