Add the frontend guides the v2 UI kit and compliance-portal need but that the first rework left uncovered: forms, routing, client state, and permission-gated UI. forms.md documents a tiered approach on Base UI Field/Form -- native constraints, then a validate function, then zod parsed in onSubmit, and react-hook-form only for large or dynamic forms -- and drops the custom useFormWithSchema wrapper. routing.md covers @probo/routes, navigation, typed params, URL-as-state, redirects, auth/protected routes, and the folded-in no-outlet-context rule. state-management.md gives a decision order across Relay, URL, local state, context, and zustand. permissions.md gates UI on the canUpdate/canDelete permission(action:) fields without re-encoding authorization in the client. Rename v2-colors.md to v2-tokens.md and add the typography, radius, shadow, and native-spacing scales alongside color. Extend ui.md with user feedback, empty-state, and accessibility sections; standardize toasts on Base UI's Toast (Toast.useToastManager) and retire the legacy useToast across ui.md, forms.md, error-handling.md, and relay.md. Add an Intl formatting section to i18n.md and a non-Relay HTTP / file upload-download section to ts-style.md. Update the AGENTS.md index and the v2-color-scale cursor rule for the new and renamed guides. Signed-off-by: Émile Ré <emile@probo.com>
7.2 KiB
Routing, navigation, and auth
Probo frontends route with React Router (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; this guide is about the routing API itself.
Related guides
| Topic | Guide |
|---|---|
Where routes.ts lives, route-segment folders |
contrib/claude/app-arborescence.md |
Loaders, queryRef, preloading |
contrib/claude/relay.md |
| Route error boundaries | contrib/claude/error-handling.md |
| Permission-gated UI within a route | contrib/claude/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.
// 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:
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, andchildren— no Relay logic in the route object (that lives in the*Loader; seerelay.md). - Use
lazy()from@probo/react-lazyfor theComponentso every page is code-split. Fallbackis 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).
import { Link, useNavigate } from "react-router";
// Declarative — preferred
<Link to={`measures/${measureId}`}>{t("measures.view")}</Link>
// 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 — 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). Params are always string | undefined; narrow before use.
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.
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 for when to choose the URL over local/global state.
Redirects
Redirect from a route loader (throwing redirect) for canonical/index redirects, and with <Navigate> for render-time redirects (e.g. role-based landing).
// Index redirect from a loader
{ index: true, loader: () => { throw redirect("general"); } }
// Render-time redirect
<Navigate to="login" replace />
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.
// 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 <Navigate to={{ pathname: "/auth/login", search: `?${search}` }} />;
}
return <PageError error={error instanceof Error ? error : new Error("unknown error")} />;
}
// 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 <Navigate to="employee" />;
case Role.AUDITOR: return <Navigate to="measures" />;
default: return <Navigate to="tasks" />;
}
}
Rules:
- The server authorizes every request; the client redirects on
UnAuthenticatedErrorpurely for UX. - Per-action gating inside an authenticated page uses
permission(action:)booleans (seepermissions.md), not role checks. - Attach
ErrorBoundaryat the boundary you want auth failures to bubble to (root for whole-app, a section boundary for embedded widgets — seeerror-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.
// Bad — layout fetches and forwards domain data through Outlet context
<Outlet context={{ showBranding: banner.showBranding }} />;
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.