# Routing, navigation, and auth Probo frontends route with [React Router](https://reactrouter.com/) (`react-router` v8), wrapped by the **`@probo/routes`** helpers and lazy-loaded with **`@probo/react-lazy`**. This guide covers how routes are declared, how to navigate and read params, how to use the URL as state, and how authenticated/protected routes are composed. Folder placement of route files is covered in [`app-arborescence.md`](app-arborescence.md); this guide is about the routing API itself. ## Related guides | Topic | Guide | |-------|--------| | Where `routes.ts` lives, route-segment folders | [`contrib/claude/app-arborescence.md`](app-arborescence.md) | | Loaders, `queryRef`, preloading | [`contrib/claude/relay.md`](relay.md) | | Route error boundaries | [`contrib/claude/error-handling.md`](error-handling.md) | | Permission-gated UI within a route | [`contrib/claude/permissions.md`](permissions.md) | ## `AppRoute` and the route tree Routes are declared as `AppRoute[]` and converted with `routeFromAppRoute` before being handed to `createBrowserRouter`. `AppRoute` extends React Router's `RouteObject` with a `Fallback` component; `routeFromAppRoute` wraps the `Component` in a `Suspense` boundary using that `Fallback` automatically. ```ts // routes.ts — one per resource folder (see app-arborescence.md) import { lazy } from "@probo/react-lazy"; import { type AppRoute } from "@probo/routes"; import { MeasuresPageSkeleton } from "./MeasuresPageSkeleton"; export const measureRoutes = [ { path: "measures", Fallback: MeasuresPageSkeleton, Component: lazy(() => import("./MeasuresPageLoader")), }, { path: "measures/:measureId", Component: lazy(() => import("./MeasureDetailLayoutLoader")), children: [ { path: "overview", Component: lazy(() => import("./overview/MeasureOverviewPage")) }, ], }, ] satisfies AppRoute[]; ``` The app root spreads each resource's routes and maps them once: ```tsx import { routeFromAppRoute } from "@probo/routes"; import { measureRoutes } from "./pages/organizations/measures/routes"; const routes = [ { path: "/", Component: lazy(() => import("./pages/MainLayout")), children: [...measureRoutes] }, ] satisfies AppRoute[]; export const router = createBrowserRouter(routes.map(routeFromAppRoute)); ``` Rules: - Routes declare only `path`, `Fallback`, `Component` (a lazy loader), `ErrorBoundary`, and `children` — **no Relay logic in the route object** (that lives in the `*Loader`; see [`relay.md`](relay.md)). - Use `lazy()` from `@probo/react-lazy` for the `Component` so every page is code-split. - `Fallback` is the route-level skeleton; reuse the page's `*Skeleton`. ## Navigation Navigate **declaratively** with `Link` for anything the user clicks, and **imperatively** with `useNavigate` only after an effect (e.g. post-submit redirect). ```tsx import { Link, useNavigate } from "react-router"; // Declarative — preferred {t("measures.view")} // Imperative — only when navigation follows an action const navigate = useNavigate(); navigate(`measures/${newId}`); ``` Build paths from segments; never hand-concatenate query strings (see [`ts-style.md`](ts-style.md) — use `URL` / `URLSearchParams`). ## Route params Read params with `useParams` **inside the component that needs them** — do not drill them as props from a parent that only read the URL to pass them down (see [`react-components.md`](react-components.md#props-are-for-configuration-and-composition-not-data)). Params are always `string | undefined`; narrow before use. ```tsx const { measureId } = useParams<{ measureId: string }>(); if (measureId == null) { return null; } ``` Prefer a small dedicated hook (`useOrganizationId()`-style) when the same param is read across many components. ## URL as state (search params) State that should survive reload, be shareable, or be linkable — the active tab, a filter, a sort, a search term, pagination cursors — belongs in the **URL**, not `useState`. Use `useSearchParams`. ```tsx import { useSearchParams } from "react-router"; const [searchParams, setSearchParams] = useSearchParams(); const status = searchParams.get("status") ?? "OPEN"; function onStatusChange(next: string) { setSearchParams((prev) => { prev.set("status", next); return prev; }); } ``` See [`state-management.md`](state-management.md) for when to choose the URL over local/global state. ## Redirects Redirect from a route `loader` (throwing `redirect`) for canonical/index redirects, and with `` for render-time redirects (e.g. role-based landing). ```tsx // Index redirect from a loader { index: true, loader: () => { throw redirect("general"); } } // Render-time redirect ``` ## Auth and protected routes Authentication state lives in a **provider** near the root (a viewer / current-user context), not in route objects. Protected subtrees are composed by nesting routes under a layout/provider that loads the viewer; unauthenticated access is handled by the **route error boundary**, which redirects to login. ```tsx // RootErrorBoundary — redirect to login on an auth error, render the error page otherwise export function RootErrorBoundary() { const error = useRouteError(); if (error instanceof UnAuthenticatedError) { const search = new URLSearchParams({ continue: window.location.href }); return ; } return ; } ``` ```tsx // Role-based landing — redirect at render time based on the viewer's role function OrganizationIndex() { const { role } = use(CurrentUser); switch (role) { case Role.EMPLOYEE: return ; case Role.AUDITOR: return ; default: return ; } } ``` Rules: - The **server** authorizes every request; the client redirects on `UnAuthenticatedError` purely for UX. - Per-action gating inside an authenticated page uses `permission(action:)` booleans (see [`permissions.md`](permissions.md)), not role checks. - Attach `ErrorBoundary` at the boundary you want auth failures to bubble to (root for whole-app, a section boundary for embedded widgets — see [`error-handling.md`](error-handling.md)). ## No domain data through `Outlet` context A layout **must not** pass fetched domain data to child routes via `useOutletContext`. Each child page that needs data follows the Loader + Page pattern with its **own** query — the same as a sibling page. This keeps every page independently loadable and avoids hidden coupling to the parent's query. ```tsx // Bad — layout fetches and forwards domain data through Outlet context ; const { showBranding } = useOutletContext<{ showBranding: boolean }>(); // Good — the child page owns its loader + query (see relay.md) const [queryRef, loadQuery] = useQueryLoader(snippetPageQuery); useEffect(() => { loadQuery({ cookieBannerId }); }, [loadQuery, cookieBannerId]); ``` `Outlet` context is fine for **non-domain** UI coordination (a layout-owned callback, a `ref`), never for loaded entities or URL ids a child can read itself.