Routing
Routes are files under routes/. The file path is the URL - no route config to maintain.
Conventions
index.tsx→ the parent path;about.tsx→/about.[id].tsx→ a dynamic segment:id(read viac.params.id/ the loader).[...path].tsx→ a catch-all capturing the rest of the URL into one param (params.path="a/b/c"). Must be the last segment; matches one or more segments (so/fileswon't match/files/[...path]).[[lang]].tsx→ an optional segment: it matches both with and without the segment.[[lang]]/about.tsxserves/about(params.lang === undefined) and/:lang/about- handy for an optional locale prefix. It expands to one route per combination, all sharing the page + layout chain (sonoptionals →2ⁿpatterns).(group)/→ a route group: the folder organizes routes (and can hold its own_layout.tsx) without adding a URL segment - e.g.(marketing)/pricing.tsx→/pricing._layout.tsxwraps its directory; nesting them builds a layout chain (this docs sidebar is a nested layout)._404.tsxrenders unmatched paths._error.tsxis the segment's error boundary. On the server - if a route's loader or shell render throws - the nearest_error(in the route's ancestor chain) renders in its place, wrapped by the layouts at/above that segment, at status 500 (served non-hydrated). On the client - a render error during navigation/interaction is caught by the nearest boundary, which renders_errorin place (all five adapters). It receives the serialized error as{ data: { name, message } }(never the stack); a thrownResponse(e.g. a guardredirect) passes through.
routes/
_layout.tsx wraps every page (chain: outer → inner)
_error.tsx error boundary (a loader throws → renders here, 500)
index.tsx → /
about.tsx → /about
users/
[id].tsx → /users/:id dynamic segment
files/
[...path].tsx → /files/*path catch-all (the rest of the path)
[[lang]]/ optional segment - matches WITH and WITHOUT it
docs.tsx → /docs AND /:lang/docs
(marketing)/ route group: organizes + can hold its own _layout,
_layout.tsx but contributes NO URL segment
pricing.tsx → /pricingA route
Each route default-exports a component; an optional meta export drives <head> (applied on SSR and on client navigation). Add a loader for data - see Loaders & actions.
// routes/users/[id].tsx
export const meta = { title: "User" } // injected into <head> (SSR + client nav)
export default function User(props: { data: LoaderData<typeof loader> }) {
return <h1>User {props.data.id}</h1>
}Catch-all routes
A [...name].tsx segment matches the rest of the path and hands it to your loader as a single string param - ideal for docs/CMS trees, file browsers, or a custom fallback. It must be the final segment.
// routes/files/[...path].tsx → matches /files/a, /files/a/b/c.txt, …
export async function loader({ params }) {
const path = params.path // "a/b/c.txt" - the matched tail, as one string
return { file: await read(path) }
}
// A catch-all needs ≥1 segment (/files alone won't match) and must be the last segment.Typed search params
Export a searchSchema - any Standard Schema (valibot, zod, arktype) - and the URL query becomes typed and validated on both sides: the loader receives it as ctx.search and the component reads the same value with useSearch<typeof searchSchema>(). Invalid or hostile input fails closed to the schema's defaults (never a 500), and the value is derived identically on the server and on each client navigation - so a query-reading page hydrates with no mismatch and never touches window.location.search by hand.
// routes/reports.tsx - a typed, validated ?page=&sort= query.
import { useSearch } from "@nifrajs/web-react/router"
import * as v from "valibot" // any Standard Schema works (valibot, zod, arktype)
// The route's search contract. Invalid or hostile input fails closed to these defaults - never a 500.
export const searchSchema = v.object({
page: v.optional(v.fallback(v.number(), 1), 1),
sort: v.optional(v.picklist(["new", "top"]), "new"),
})
// The loader receives the validated query as ctx.search, typed by the third LoaderArgs argument.
export async function loader({ search, api }: LoaderArgs<typeof backend, unknown, typeof searchSchema>) {
return { rows: await api.reports.list(search).get() } // search.page is a number
}
// The component reads the SAME value - SSR-correct, so page/sort hydrate with no mismatch and you
// never parse window.location.search by hand.
export default function Reports({ data }: { data: LoaderData<typeof loader> }) {
const { page, sort } = useSearch<typeof searchSchema>() // { page: number; sort: "new" | "top" }
return <Pager page={page} sort={sort} rows={data.rows} />
}Without a searchSchema, ctx.search and useSearch() are the raw parsed query (Record<string, unknown>). useSearch ships on every adapter (React, Preact, Vue, Solid, Svelte), each in that framework's shape - a value on React/Preact, a Ref on Vue, an accessor on Solid/Svelte. For imperative reads and writes of the raw query, useSearchParams() mirrors react-router's [params, setParams] tuple.
To WRITE search, useNavigate takes an object target: navigate({ to: "/reports", search: { page: 2 } }) serializes search onto to (no hand-built query strings). Run nifra sync-routes to generate nifra-routes.d.ts (each static route mapped to its schema output) and include it in your tsconfig, and search becomes typed against the target route's schema - a wrong shape for a known route is a compile error, while any other path takes a loose search. Re-run it after adding a route or changing a searchSchema; a stale shape is a tsc error. The plain string-path and history-delta forms (navigate("/about"), navigate(-1)) are unchanged.
Guarding navigation
A page with unsaved work shouldn't lose it to a stray click or the back button. useBlocker (from @nifrajs/web-react/router) intercepts a navigation - a <Link>/anchor click, useNavigate, or a browser back/forward - and hands you { state, proceed, reset }. Pass a boolean or a ({ currentLocation, nextLocation }) => boolean predicate; when a navigation is held, state becomes "blocked", so you render your OWN confirmation and call proceed() to continue or reset() to stay. It mirrors react-router's shape - a plain boolean can't express an async "are you sure?", these two callbacks can.
// routes/posts/[id]/edit.tsx - don't lose a half-finished edit to a stray click.
import { useState } from "react"
import { useBlocker } from "@nifrajs/web-react/router"
export default function EditPost() {
const [dirty, setDirty] = useState(false)
// A boolean, or a predicate of { currentLocation, nextLocation } for finer control
// (e.g. allow moves within the editor, block only real exits).
const blocker = useBlocker(dirty)
return (
<form onInput={() => setDirty(true)} onSubmit={() => setDirty(false)}>
{/* ...fields... */}
{blocker.state === "blocked" && (
<div role="dialog" aria-modal="true">
<p>You have unsaved changes.</p>
<button type="button" onClick={blocker.reset}>Keep editing</button>
<button type="button" onClick={blocker.proceed}>Discard</button>
</div>
)}
</form>
)
}It also arms the browser's native "Leave site?" prompt on tab close / reload (the browser shows its own text there - a custom message isn't possible). On the server and before hydration the blocker is idle (it never blocks), so navigation degrades to the native <a> and the page is hydration-safe.
useNavigate and useBlocker ship on every adapter - @nifrajs/web-<framework>/router on Preact, Vue, Solid and Svelte too, each returning the blocker in that framework's own shape (a Vue ref, a Solid accessor, a Svelte store, a plain value in Preact/React). In Vue, Solid and Svelte the hook is created once, so pass a function - useBlocker(() => dirty) - to track a changing flag.