Navigation Guards
How frontend route guards protect pages based on authentication state, permissions, and roles — with a complete decision matrix and route inventory.
Overview
The frontend uses TanStack Router's beforeLoad hook to enforce access control before a page renders. This prevents flashes of forbidden content and provides a smooth redirect experience.
Three guard functions handle all navigation scenarios:
| Guard | Purpose | Source |
|---|---|---|
requireAuth() | Redirect unauthenticated users to /login | apps/web/src/lib/routeGuards.ts |
requireGuest() | Redirect authenticated users away from auth pages | apps/web/src/lib/routeGuards.ts |
enforceRoutePermission() | Check org-level permissions or system roles | apps/web/src/lib/routePermissions.ts |
The backend enforces the same rules via the global AuthGuard (see Auth & Security Architecture). Frontend guards exist for UX — they prevent unnecessary page loads and provide meaningful redirects. The backend remains the source of truth.
Navigation Decision Matrix
This table covers every user-state and page-type combination:
| User State | Page Type | Example Routes | Outcome |
|---|---|---|---|
| Not authenticated | Public | /, /legal/*, /design-system, /docs/* | Renders normally |
| Not authenticated | Guest-only | /login, /register, /reset-password | Renders normally |
| Not authenticated | Protected | /dashboard, /settings/* | Redirect to /login?redirect=<current_path> |
| Not authenticated | Permission-gated | /admin/* | Redirect to /login |
| Authenticated | Public | /, /legal/*, /design-system, /docs/* | Renders normally |
| Authenticated | Guest-only | /login, /register, /reset-password | Redirect to /dashboard |
| Authenticated | Protected | /dashboard, /settings/* | Renders normally |
| Authenticated + has permission | Org permission-gated | /admin/members, /admin/settings | Renders normally |
| Authenticated + missing permission | Org permission-gated | /admin/members, /admin/settings | Redirect to /dashboard |
| Authenticated + superadmin | System role-gated | /admin/users, /admin/audit-logs, /admin/organizations | Renders normally |
| Authenticated + not superadmin | System role-gated | /admin/users, /admin/audit-logs, /admin/organizations | Redirect to /admin |
| Soft-deleted account | Any protected | /dashboard, /admin/* | Backend returns 401/403, treated as unauthenticated |
Guard Functions
requireAuth()
Used in beforeLoad for pages that need an authenticated user. Reads the session from ctx.context.session — populated by the root route's beforeLoad (see Root Context Auth below). If no session exists, redirects to /login with the current path captured as a redirect search parameter.
export const Route = createFileRoute('/dashboard')({
beforeLoad: requireAuth,
component: DashboardPage,
})After login, safeRedirect() validates the captured redirect URL before navigating — blocking protocol-relative URLs (//), URL-encoded traversal (/%2F, /%5C), and cross-origin redirects. Invalid values fall back to /dashboard.
requireGuest()
Used in beforeLoad for auth pages (login, register, reset password). If the user already has a valid session, redirects to /dashboard.
export const Route = createFileRoute('/login')({
beforeLoad: requireGuest,
component: LoginPage,
})enforceRoutePermission()
A generic guard that reads staticData.permission from the route definition. Supports two permission formats:
| Format | Example | Check | Redirect on Failure |
|---|---|---|---|
| Permission string | members:write | Checks session.permissions array | /dashboard |
| Role prefix | role:superadmin | Checks session.user.role | /admin |
Superadmin users automatically pass all org-level permission checks.
export const Route = createFileRoute('/admin/members')({
staticData: { permission: 'members:write' },
beforeLoad: enforceRoutePermission,
component: AdminMembersPage,
})The staticData.permission field is type-safe via declaration merging on TanStack Router's StaticDataRouteOption interface (defined in apps/web/src/lib/routePermissions.ts).
Client-Side Navigation Flow
Complete Route Inventory
Public Pages (no guard)
| Route | Description |
|---|---|
/ | Landing page |
/legal/* | Legal pages (CGU, privacy, cookies, mentions legales) |
/design-system | Design system showcase |
/docs/$ | Documentation (catch-all) |
/talks, /talks/claude-code, /talks/dev-process | Talk pages |
/magic-link/verify | Magic link verification callback |
/magic-link-sent | Confirmation page shown after magic link is sent |
/verify-email | Email verification callback |
/account-deleted | Deletion confirmation |
/account-reactivation | Account recovery |
/$ | Catch-all 404 |
Guest-Only Pages (requireGuest)
| Route | Redirect if authenticated |
|---|---|
/login | /dashboard |
/register | /dashboard |
/reset-password | /dashboard |
/reset-password/confirm | /dashboard |
Protected Pages (requireAuth)
| Route | Description |
|---|---|
/dashboard | Main dashboard |
/settings | Settings layout (children inherit auth) |
/settings/profile | Profile settings (inherits from /settings) |
/settings/account | Account settings (inherits from /settings) |
/settings/api-keys | API key management (inherits from /settings) |
Admin — Org-Level (enforceRoutePermission)
| Route | Permission | Redirect on Failure |
|---|---|---|
/admin | members:write | /dashboard |
/admin/members | members:write | /dashboard |
/admin/settings | members:write | /dashboard |
Admin — System-Level (enforceRoutePermission)
| Route | Permission | Redirect on Failure |
|---|---|---|
/admin/users | role:superadmin | /admin |
/admin/users/$userId | role:superadmin | /admin |
/admin/audit-logs | role:superadmin | /admin |
/admin/organizations | role:superadmin | /admin |
/admin/organizations/$orgId | role:superadmin | /admin |
/admin/feature-flags | role:superadmin | /admin |
/admin/system-settings | role:superadmin | /admin |
/admin/health | role:superadmin (disabled) | /admin |
Legacy Redirects
| Route | Redirects to |
|---|---|
/org | /admin |
/org/members | /admin/members |
/org/settings | /admin/settings |
/changelog | /docs/$ |
Layout Inheritance
TanStack Router's layout routes apply beforeLoad guards to all children. Two layouts use this pattern:
/settings— appliesrequireAuth. All/settings/*routes inherit authentication without declaring their own guard./admin— appliesenforceRoutePermissionwithmembers:write. Child routes can declare additional permissions (e.g.,role:superadminfor/admin/users), which are checked in addition to the parent guard.
This means a route like /admin/users goes through two guard checks: first the /admin layout guard (members:write), then its own (role:superadmin).
SSR and Edge Cases
Root Context Auth
Guards no longer fetch the session independently. Instead, the root route (__root.tsx) fetches the session once in its beforeLoad via getServerEnrichedSession() — a createServerFn defined in routePermissions.ts:
- During SSR: the server function executes inline, forwarding cookies via
getRequestHeader. No client round-trip occurs. - During client-side navigation: the server function runs as an RPC call.
The session is returned from root beforeLoad and merged into TanStack Router's context (MyRouterContext). All child route guards simply read ctx.context.session — zero additional API calls and no SSR environment checks. Auth is enforced identically on SSR and in the browser.
// router.tsx — initial context
createRouter({ context: { session: null } })
// __root.tsx — root beforeLoad
const session = await getServerEnrichedSession()
return { session }
// routeGuards.ts — guard reads from context
export function requireAuth(ctx: BeforeLoadContext) {
if (!ctx.context.session) throw redirect({ to: '/login', ... })
}Session Fetch Failures
When getServerEnrichedSession() encounters a network error or the API is unavailable, it catches the error and returns null. Guards treat a null session as unauthenticated and redirect accordingly. There is no retry logic at the guard level.
Post-Login Redirect
When requireAuth() redirects to /login, it captures the original path:
/login?redirect=/admin/membersAfter successful login, safeRedirect() validates the URL before navigating. Invalid redirect values (cross-origin, protocol-relative, encoded traversal) are silently replaced with /dashboard.
UI-Level Permission Checks
Beyond route guards, individual components use useCanAccess(path) and hasPermission(session, permission) to conditionally render UI elements (navigation links, buttons, menu items). This prevents showing links to pages the user cannot access.
File Reference
| File | Purpose |
|---|---|
apps/web/src/routes/__root.tsx | Root route — fetches session via getServerEnrichedSession() server function, provides it to all child routes via context |
apps/web/src/lib/routeGuards.ts | requireAuth(), requireGuest(), safeRedirect() |
apps/web/src/lib/routePermissions.ts | enforceRoutePermission(), getServerEnrichedSession(), useCanAccess(), useEnrichedSession() |
apps/web/src/lib/permissions.ts | hasPermission(), hasAllPermissions(), hasAnyPermission() |
Related Documentation
- Authentication Guide — Backend auth guard, decorators, session management
- RBAC Guide — Permission model, protecting endpoints, frontend permission helpers
- Auth & Security Architecture — Guard evaluation order, session lifecycle, cookie strategy
- Frontend Architecture — TanStack Router, file-based routing, SSR
RBAC (Roles and Permissions)
How to use the Role-Based Access Control system to protect endpoints, check permissions in components, and manage custom roles.
Multi-tenant Architecture
How tenant isolation works in Roxabi -- from the request interceptor through CLS context to PostgreSQL Row-Level Security.