efine the Access Contract
Begin by establishing TypeScript interfaces that describe route policies and user claims. This creates a strict contract between your authentication store and the guard engine.
export type AccessLevel = 'public' | 'guest-only' | 'authenticated';
export interface RoutePolicy {
path: string;
access: AccessLevel;
roles?: string[];
permissions?: string[];
requireAllPermissions?: boolean;
}
export interface UserClaims {
id: string;
roles: string[];
permissions: string[];
isAuthenticated: boolean;
}
Step 2: Implement the Policy Evaluator
The core guard logic must remain completely independent of React, routing libraries, or UI frameworks. It should accept a route policy and user claims, then return a deterministic access decision.
export interface GuardResult {
allowed: boolean;
redirectTarget: string | null;
reason: 'unauthenticated' | 'insufficient-role' | 'insufficient-permission' | 'guest-restricted' | null;
}
export class AccessEvaluator {
constructor(
private readonly claimsProvider: () => UserClaims | null,
private readonly roleMatcher: (userRoles: string[], requiredRoles: string[]) => boolean,
private readonly permissionMatcher: (userPerms: string[], requiredPerms: string[], requireAll: boolean) => boolean
) {}
evaluate(policy: RoutePolicy, currentPath: string): GuardResult {
const user = this.claimsProvider();
// Guest-only routes block authenticated users
if (policy.access === 'guest-only' && user?.isAuthenticated) {
return { allowed: false, redirectTarget: '/dashboard', reason: 'guest-restricted' };
}
// Authenticated routes block unauthenticated users
if (policy.access === 'authenticated' && !user?.isAuthenticated) {
return { allowed: false, redirectTarget: '/login', reason: 'unauthenticated' };
}
// Role validation (OR logic by default)
if (policy.roles?.length) {
const hasRole = this.roleMatcher(user!.roles, policy.roles);
if (!hasRole) {
return { allowed: false, redirectTarget: '/access-denied', reason: 'insufficient-role' };
}
}
// Permission validation (AND or OR based on config)
if (policy.permissions?.length) {
const hasPermissions = this.permissionMatcher(
user!.permissions,
policy.permissions,
policy.requireAllPermissions ?? true
);
if (!hasPermissions) {
return { allowed: false, redirectTarget: '/access-denied', reason: 'insufficient-permission' };
}
}
return { allowed: true, redirectTarget: null, reason: null };
}
}
Step 3: Bridge to the Router
The adapter layer translates guard decisions into router-specific navigation actions. For React Router, this is typically implemented as a wrapper component that intercepts rendering and triggers programmatic navigation when access is denied.
import { useNavigate, Outlet, useLocation } from 'react-router-dom';
import { useEffect, useMemo } from 'react';
import { AccessEvaluator, RoutePolicy } from './access-evaluator';
interface GuardRouteProps {
policy: RoutePolicy;
evaluator: AccessEvaluator;
fallbackPath: string;
}
export function RouteGuard({ policy, evaluator, fallbackPath }: GuardRouteProps) {
const navigate = useNavigate();
const location = useLocation();
const decision = useMemo(
() => evaluator.evaluate(policy, location.pathname),
[evaluator, policy, location.pathname]
);
useEffect(() => {
if (!decision.allowed && decision.redirectTarget) {
navigate(decision.redirectTarget, { replace: true, state: { from: location.pathname } });
}
}, [decision, navigate, location.pathname]);
if (!decision.allowed) return null;
return <Outlet />;
}
Step 4: Apply Declarative Route Definitions
Route policies are now defined as data. This enables both config-driven and JSX-composed patterns without duplicating guard logic.
// Config-driven approach
export const appRoutePolicies: RoutePolicy[] = [
{ path: '/', access: 'public' },
{ path: '/login', access: 'guest-only' },
{ path: '/dashboard', access: 'authenticated' },
{ path: '/admin', access: 'authenticated', roles: ['super-admin', 'admin'] },
{
path: '/billing',
access: 'authenticated',
permissions: ['billing:view', 'billing:manage'],
requireAllPermissions: false
},
];
// JSX composition approach
export function ProtectedApp() {
const evaluator = useMemo(() => new AccessEvaluator(
() => useAuthStore.getState().currentUser,
(userRoles, required) => required.some(r => userRoles.includes(r)),
(userPerms, required, all) => all ? required.every(p => userPerms.includes(p)) : required.some(p => userPerms.includes(p))
), []);
return (
<Routes>
<Route element={<RouteGuard policy={appRoutePolicies[0]} evaluator={evaluator} fallbackPath="/" />}>
<Route index element={<HomePage />} />
</Route>
<Route element={<RouteGuard policy={appRoutePolicies[1]} evaluator={evaluator} fallbackPath="/dashboard" />}>
<Route path="login" element={<LoginPage />} />
</Route>
<Route element={<RouteGuard policy={appRoutePolicies[2]} evaluator={evaluator} fallbackPath="/login" />}>
<Route path="dashboard" element={<DashboardPage />} />
</Route>
</Routes>
);
}
Architecture Decisions & Rationale
- Framework-Agnostic Core: The
AccessEvaluator contains zero UI dependencies. This allows the same policy logic to run in TanStack Router's beforeLoad hooks, Next.js middleware, or server-side rendering pipelines without duplication.
- Declarative Policy Objects: Routes are defined as plain data structures. This enables static analysis, automated testing of access matrices, and runtime policy injection from backend configuration services.
- Explicit Redirect State: The guard preserves the originating path in navigation state (
state: { from: location.pathname }). This enables post-authentication deep linking, a critical UX requirement often missed in ad-hoc implementations.
- Memoized Evaluation: Guard decisions are computed via
useMemo and only re-evaluate when the policy, evaluator, or location changes. This prevents unnecessary re-renders during high-frequency state updates.
Pitfall Guide
1. Stale Closure in Auth State Readers
Explanation: Guard components that read authentication state directly inside useEffect or event handlers often capture outdated values due to JavaScript closure behavior. This causes redirects to trigger with stale user data.
Fix: Always read auth state synchronously during render, or use a reactive store subscription that forces component re-evaluation. Wrap state readers in useCallback with explicit dependency arrays.
2. Redirect Loops on Policy Mismatch
Explanation: When a guard redirects to a login page that is itself protected by an authenticated-only rule, the router enters an infinite navigation cycle.
Fix: Explicitly mark authentication endpoints as guest-only or public. Implement a guard priority queue that evaluates guest-only rules before authenticated rules.
3. Client-Side Only Enforcement
Explanation: Route guards only control UI rendering. They do not prevent direct API requests or deep-link access to protected resources.
Fix: Treat client-side guards as UX optimization, not security boundaries. Always validate permissions on the backend API layer. Use guard policies to generate API request headers or route to permission-scoped data loaders.
Explanation: Evaluating complex permission matrices on every navigation event can block UI rendering, especially when permissions are fetched from remote services or computed via recursive role hierarchies.
Fix: Cache user claims locally after authentication. Implement lazy permission evaluation that only runs when a route explicitly requires it. Use Web Workers or background threads for expensive role hierarchy resolution.
5. Hydration Mismatches in SSR/SSG
Explanation: Server-rendered applications may generate different guard decisions than the client if authentication state is not synchronized during hydration. This causes React hydration warnings and broken route trees.
Fix: Serialize initial auth state into the HTML payload. Ensure the guard evaluator uses the same synchronous data source during both server rendering and client hydration. Defer dynamic permission checks to client-side navigation only.
6. Overlapping RBAC and ABAC Logic
Explanation: Mixing role-based and attribute-based checks without clear precedence rules creates ambiguous access decisions. A user might have the required role but lack a specific permission, or vice versa.
Fix: Define explicit evaluation order in your policy engine. Typically, roles act as broad access gates, while permissions refine access within those gates. Document the AND/OR semantics clearly and enforce them through TypeScript types.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small internal tool (<10 routes) | JSX Composition Layer | Faster initial setup, better IDE autocomplete, easier to read for junior developers | Low setup, medium maintenance |
| Enterprise SaaS with dynamic permissions | Config-Driven Policy Engine | Centralized policy management, enables runtime policy injection, simplifies audit trails | Medium setup, low maintenance |
| Multi-router architecture (React + TanStack + Next) | Framework-Agnostic Core + Adapters | Eliminates duplicate guard logic, ensures consistent policy evaluation across frameworks | High initial investment, near-zero cross-framework migration cost |
| High-security financial platform | Config-Driven + Backend Policy Sync | Enables policy versioning, audit logging, and real-time permission revocation | High compliance cost, reduced security risk |
Configuration Template
Copy this TypeScript configuration to bootstrap a production-ready guard system. Adjust the auth store and matcher functions to match your domain model.
// guard.config.ts
import { AccessEvaluator, RoutePolicy } from './access-evaluator';
import { useAuthStore } from './auth-store';
export const routePolicies: RoutePolicy[] = [
{ path: '/', access: 'public' },
{ path: '/auth/login', access: 'guest-only' },
{ path: '/auth/register', access: 'guest-only' },
{ path: '/app', access: 'authenticated' },
{ path: '/app/settings', access: 'authenticated', permissions: ['settings:manage'] },
{ path: '/admin', access: 'authenticated', roles: ['admin', 'super-admin'] },
];
export function createGuardEvaluator() {
return new AccessEvaluator(
() => useAuthStore.getState().user,
(userRoles, required) => required.some(r => userRoles.includes(r)),
(userPerms, required, requireAll) =>
requireAll
? required.every(p => userPerms.includes(p))
: required.some(p => userPerms.includes(p))
);
}
export const guardEvaluator = createGuardEvaluator();
Quick Start Guide
- Install dependencies: Add your router package and state management library. No external guard library is required if you implement the pattern above.
- Define your policy contract: Create
RoutePolicy and UserClaims interfaces matching your authentication model.
- Implement the evaluator: Copy the
AccessEvaluator class and customize the role/permission matchers to align with your backend API response structure.
- Wire the router adapter: Create a
RouteGuard component that consumes the evaluator and triggers navigation on denied access. Wrap protected routes with this component.
- Test edge cases: Verify redirect loops, stale state handling, and permission matrix evaluation using unit tests before deploying to production.