Docs

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 via c.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 /files won't match /files/[...path]).
  • [[lang]].tsx → an optional segment: it matches both with and without the segment. [[lang]]/about.tsx serves /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 (so n optionals → 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.tsx wraps its directory; nesting them builds a layout chain (this docs sidebar is a nested layout).
  • x.backend.ts beside a page or layout is its server half: loader, action, their output schemas, and the other server-only exports. It never reaches the browser - see Project structure.
  • _404.tsx renders unmatched paths, and whatever a loader answers with notFound(). At the routes root it renders inside the root layout, as below. In a directory below (admin/_404.tsx) it answers for that part of the app: the unmatched URLs under /admin and notFound() from the routes beneath it, the nearest one winning, rendered inside the layouts at or above it with their loader data. Those layouts run as they do for a page, so a gate decides first - its redirect is the answer, and its own notFound() shows the root page rather than the area's. A nested _404 is served non-hydrated and never from a shared cache once a layout loaded data for it; a navigation that lands on one loads the URL as a document. Two route groups with a _404 at one URL prefix are a boot error unless a directory containing both has one too. gone(), statusPage() and the _410.tsx-style pages stay at the root, as does the 404 of a static export.
  • A root status page (_404.tsx, _503.tsx, ...) or an _error.tsx renders inside the layouts above it with their loader data, and the server puts its meta in the head. For a request no route ran (an unmatched URL) the root middleware and layout loaders run first; otherwise the page reuses the data the request already loaded. When those layouts cannot render it - a loader throws, a gate or the middleware refuses - a root status page renders alone and an _error inside its layouts without their data, with the same status, so a status page never fails into a second error. The response is private, no-store once a layout loaded data, and a navigation that lands on one loads the URL as a document. export const inLayouts = false always renders the page that way and runs no layout loader for it. A root status page that wraps the root layout itself would render it twice; nifra check reports one as NF-C034.
  • _loading.tsx is what the page slot shows while a client navigation loads. A navigation still waiting on its data after about 120 ms swaps the page for the target route's nearest _loading: the innermost one above it whose layouts are already on screen. Those are the layouts the two pages share, and they stay mounted with their data. A _loading never renders outside a layout above it - coming from outside /admin, admin/_loading.tsx waits for the admin layout and the root one shows instead. A navigation that settles sooner (a prefetched link, a fast loader) goes straight to the new page, as does one with no eligible _loading; a change of search on the same path and a form submit keep the page and report pending. The component receives pending and no data. It is a browser-only convention on every adapter - the first load of a URL is a server render, where defer() streams the slow part.
  • _error.tsx is 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 _error in place (all five adapters). It receives the serialized error as { data: { name, message } } (never the stack; on the server, a generic Internal Server Error rather than the error's own text unless NODE_ENV is development or test); a thrown control-flow value (e.g. a guard redirect) passes through.
  • A _layout.backend.ts that exports middleware runs it on the server before the layouts, loaders and actions of every route in its directory and below, with or without a _layout.tsx beside it - see Route middleware.
TS
routes/
  _layout.tsx        wraps every page (chain: outer → inner)
  _layout.backend.ts its loader, and middleware for every page below it
  _error.tsx         error boundary (a loader throws → renders here, 500)
  _loading.tsx       the page slot while a client navigation loads
  index.tsx          →  /
  index.backend.ts       its loader + action - never reaches the browser
  about.tsx          →  /about
  users/
    [id].tsx         →  /users/:id          dynamic segment
    [id].backend.ts
  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      →  /pricing

A route

Each page default-exports a component; an optional meta export drives <head> (applied on SSR and on client navigation). Its data comes from a loader in its .backend.ts half - see Loaders & actions.

A layout's meta receives its own loader's data as data, the value its component renders, or null when the layout has no loader. The page's data is pageData, for a layout title template. A layout that loads the locale can set lang from its data, and a client navigation keeps it. nifra check reports a layout with no loader whose meta reads data as NF-C040.

TS
// routes/users/[id].tsx
import type { Route } from "./+types/[id]"

export const meta = { title: "User" }   // injected into <head> (SSR + client nav)

export default function User({ data }: Route.ComponentProps) {
  return <h1>User {data.name}</h1>
}
TS
// routes/users/[id].backend.ts - its server half
import { t } from "@nifrajs/schema"
import type { Route } from "./+types/[id]"

export const loaderOutput = t.object({ name: t.string() })

export async function loader({ params }: Route.LoaderArgs) {
  return { name: `User ${params.id}` }
}

Route middleware

A directory's _layout.backend.ts exports middleware, a function that runs on the server before everything under its directory: the layouts' loaders, gates included, then the page's loader or action. It runs for a document request, a client navigation and a form post alike, and before a nested _404 there. Middleware higher in the tree runs first.

TS
// routes/account/_layout.backend.ts - runs before every page under /account
import { type RouteMiddleware, redirect } from "@nifrajs/web"

export const middleware: RouteMiddleware = ({ request, set }) => {
  set.headers["cache-control"] = "private, no-store"
  const signedIn = request.headers.get("cookie")?.includes("session=") ?? false
  return signedIn ? undefined : redirect("/login")
}
  • Return nothing to let the request through. Return or throw a redirect(), a status such as notFound(), or a Response to answer with it, and nothing below it runs. During a client navigation, the router follows a redirect to another page of the app without reloading, and the address bar shows the target.
  • set adds headers and cookies like a loader's; a layout's or the page's header of the same name wins. params holds the params of the directory's URL prefix.
  • It covers pages only: a prerendered page, an ISR cache hit, a mounted API and a static file are served without it. For middleware on every request, export use from backend/framework.ts: export const use = (app) => app.use(securityHeaders()).

Catch-all routes

A [...name].tsx segment matches the rest of the path and hands it to the loader in [...name].backend.ts as a single string param - ideal for docs/CMS trees, file browsers, or a custom fallback. It must be the final segment.

TS
// routes/files/[...path].backend.ts - the page is routes/files/[...path].tsx, and together they
// match /files/a, /files/a/b/c.txt, …
import { t } from "@nifrajs/schema"

declare function read(path: string): Promise<string>

export const loaderOutput = t.object({ file: t.string() })

export async function loader({ params }: { params: { path: string } }) {
  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.

Client-only pages

export const ssr = false keeps a page's component off the server. The route is still matched, its layouts still render, its gates and its loader still run and the data is still embedded - only the component is skipped. The server puts the page's HydrateFallback in the page slot (nothing, if there is none), the browser hydrates that, then renders the component with the data it already has. A client navigation to the page renders the component directly. For a component that reads window, a canvas, or a browser-only library while it renders.

TSX
// routes/map.tsx - a page whose component needs a browser to render.
import type { Route } from "./+types/map"

export const ssr = false

// What the server puts in the page slot, and what the browser shows until the component renders.
// Same props as the component - the loader data is already there.
export function HydrateFallback({ data }: Route.ComponentProps) {
  return <p>Loading {data.pins.length} pins…</p>
}

export default function MapPage({ data }: Route.ComponentProps) {
  const width = window.innerWidth         // fine: this never runs on the server
  return <Canvas width={width} pins={data.pins} />
}

// routes/map.backend.ts - the loader still runs on the server, and its data is embedded.
export const loaderOutput = t.object({ pins: t.array(Pin) })
export async function loader({ api }: Route.LoaderArgs) {
  const res = await api.pins.get()
  return { pins: res.ok ? res.data : [] }
}

The page module is still imported on the server, for its options (its loader lives in the .backend.ts half). An import that needs a browser at load time therefore belongs inside the component - a dynamic import() - not at the top of the file. ssr = false cannot be combined with hydrate = false: nothing would ever render the page.

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.

TSX
// 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)
import type { Route } from "./+types/reports"

// 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 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 }: Route.ComponentProps) {
  const { page, sort } = useSearch<typeof searchSchema>() // { page: number; sort: "new" | "top" }
  return <Pager page={page} sort={sort} rows={data.rows} />
}

// routes/reports.backend.ts - the loader receives the validated query as `search`, typed from
// the page's searchSchema by the generated Route.LoaderArgs.
export const loaderOutput = t.object({ rows: t.array(Report) })
export async function loader({ search, api }: Route.LoaderArgs) {
  const res = await api.reports.list(search).get() // search.page is a number
  return { rows: res.ok ? res.data : [] }
}

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.

Prefetching

A link warms its route - the route's code and its loader data - before it is followed, so the click renders at once. data-nifra-prefetch, on the link or on any element around it (the nearest one wins), says when:

  • intent (the default): on hover or keyboard focus.
  • viewport: once the link scrolls into view.
  • render: as soon as a page shows the link.
  • none: never; the route loads when the link is followed.
TSX
// A docs sidebar: each link loads as it scrolls into view.
<nav data-nifra-prefetch="viewport">
  <a href="/docs/routing">Routing</a>
  <a href="/docs/data">Data</a>
  {/* The nearest attribute wins: this one waits for the click. */}
  <a href="/reports/annual" data-nifra-prefetch="none">Annual report</a>
</nav>

// React's <Link> takes it as a prop.
<Link to="/pricing" prefetch="render">Pricing</Link>

Warmed data is used by a click within 30 seconds; after that the click loads the route again, so a link that scrolled into view long ago never shows old data. The page already on screen is never prefetched. viewport and render links are found when the page loads and whenever the router settles (a navigation, a submit); a link the page adds in between warms on intent until then. Both run the target's loaders before anyone clicks, so keep them to links people are likely to follow. The attribute works the same under every adapter; React's <Link> and <NavLink> also take it as a prefetch prop.

Breadcrumbs and the rendered chain

A page or a layout can export a handle - any value; nifra only passes it along. useMatches() returns the chain being rendered, outermost layout first and the page last, each entry as { id, pathname, params, data, handle }: id is the route file without its extension, pathname the part of the URL it covers (a layout its directory's prefix, the page the whole path, never the query), params the ones it declares, and data its loader data (null without a loader). A layout can therefore build breadcrumbs, a title, or a section nav from what the routes below it export.

TSX
// routes/orgs/[org]/_layout.tsx
import type { ReactNode } from "react"
import { useMatches } from "@nifrajs/web-react/router"

export const handle = { crumb: "Organization" }

export default function OrgLayout({ children }: { children: ReactNode }) {
  // Outermost layout first, then the page: { id, pathname, params, data, handle }.
  const crumbs = useMatches().flatMap((m) => {
    const crumb = (m.handle as { crumb?: string } | undefined)?.crumb
    return crumb === undefined ? [] : [{ href: m.pathname, crumb }]
  })
  return (
    <>
      <nav aria-label="Breadcrumb">
        {crumbs.map((c) => <a key={c.href} href={c.href}>{c.crumb}</a>)}
      </nav>
      {children}
    </>
  )
}

The server render and the browser report the same list, so a breadcrumb trail hydrates with no mismatch. handle is read from the module on each side, never serialized, so it can hold a function or a component. While a _loading page is up it takes the page's place in the list; a nested _404 reports the layouts it renders inside; an _error page the server renders reports an empty list. useMatches ships on every adapter from @nifrajs/web-<framework>/router, as an array on React/Preact, a Ref on Vue, and an accessor on Solid/Svelte.

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.

TSX
// 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.