No description
Find a file
autocommit ed1547137a
Some checks failed
Publish / publish (push) Failing after 0s
deps-upgrade(dependencies): ⬆️ Update all project dependencies to latest stable versions
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-20 20:24:26 -07:00
.forgejo/workflows chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
coverage chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
dist chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
node_modules deps-upgrade(dependencies): ⬆️ Update all project dependencies to latest stable versions 2026-05-20 20:24:26 -07:00
src chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
.gitignore chore: add .gitignore, remove node_modules/dist/.turbo from tracking 2026-04-20 01:13:12 -07:00
CLAUDE.md chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
eslint.config.js chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
package.json chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
README.md chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
REQUIRE_AUTH_EXAMPLES.md chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
ROUTING_TYPE_SYSTEM.md chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
tsconfig.json chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
tsup.config.ts chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00
vitest.config.ts chore: initial package split from monorepo 2026-04-20 01:11:33 -07:00

@lilith/ui-router

Standardized routing utilities and type-safe routing system for Lilith Platform React applications.

Purpose

This package centralizes react-router-dom dependencies and provides a comprehensive type-safe routing system with:

  • Route protection with authentication and authorization
  • Type-safe path parameters and query strings
  • Ensure consistent react-router versions across all features
  • Prevent version mismatch issues
  • Type-safe route definitions, builders, and registries
  • Route-level access control metadata

What's New in 1.2.0

Route Protection Components

  • ProtectedRoute - Unified authentication and authorization gate
  • RequireAuth - Alias for ProtectedRoute (consistent naming)
  • Prevents flash of unauthenticated content
  • Supports role-based access control (RBAC)
  • Custom authorization logic
  • Graceful fallbacks vs strict redirects

Type-Safe Hooks

  • useTypedParams - Extract path parameters with full type inference
  • useQueryParams - Type-safe query string management with useState-like API
  • useQueryParamsTyped - Explicit type parameter version

Comprehensive Type System

  • Route definitions with metadata and access control
  • Route builders for type-safe navigation
  • Route registries for centralized management
  • Router lookup tables (marketplace pattern)

Installation

pnpm add @lilith/ui-router@^1.2.0

Then remove react-router and react-router-dom from your package dependencies.

Version Matching

This package uses exact version matching with react-router-dom.

The wrapper version indicates the feature level, not the underlying react-router-dom version:

{
  "name": "@lilith/ui-router",
  "version": "1.2.0",  // Feature version (v1.2.0 = ProtectedRoute + hooks)
  "devDependencies": {
    "react-router": "7.12.0",      // Exact version
    "react-router-dom": "7.12.0"   // Exact version
  }
}

Current: @lilith/ui-router@1.2.0 wraps react-router-dom@7.12.0 with route protection and type-safe hooks.

Versioning Strategy

Feature versions (1.x.y):

  • Major (1.x): Breaking API changes
  • Minor (x.2.x): New features (ProtectedRoute, hooks, etc.)
  • Patch (x.x.1): Bug fixes, non-breaking improvements

Wrapper updates: When react-router-dom releases new versions, we evaluate and update dependencies while maintaining feature version.

Multi-Library Wrapper

This wrapper re-exports both react-router and react-router-dom. The version matches react-router-dom.

Assumption: Both libraries release in sync upstream.


Route Protection

ProtectedRoute Component

Unified authentication and authorization gate for routes. Addresses inconsistencies found in 8+ duplicate implementations across the codebase.

Basic Authentication

import { ProtectedRoute } from '@lilith/ui-router';

function App() {
  const auth = useAuth();

  return (
    <ProtectedRoute
      authState={auth}
      unauthenticatedRedirect="/login"
    >
      <Dashboard />
    </ProtectedRoute>
  );
}

With Loading State (Prevents Flash)

<ProtectedRoute
  authState={auth}
  unauthenticatedRedirect="/login"
  loadingFallback={<Spinner />}
>
  <Dashboard />
</ProtectedRoute>

Why loading state matters: During initial page load, authentication state is often being determined asynchronously. Without a loading state, users would briefly see the redirect/fallback UI before being authenticated. The loadingFallback prevents this flash of unauthenticated content.

Role-Based Access Control (RBAC)

<ProtectedRoute
  authState={auth}
  requiredRoles={['admin', 'moderator']}
  unauthenticatedRedirect="/login"
  unauthorizedRedirect="/access-denied"
>
  <AdminPanel />
</ProtectedRoute>

How roles work:

  • User needs at least one of the required roles
  • authState.roles must be provided if using requiredRoles
  • Authorization check only runs if user is authenticated

Custom Authorization Logic

const requirePremium = (auth: AuthState) =>
  auth.isAuthenticated && auth.user?.isPremium === true;

<ProtectedRoute
  authState={auth}
  authorize={requirePremium}
  unauthorizedFallback={<UpgradePrompt />}
>
  <PremiumFeature />
</ProtectedRoute>

Authorization precedence:

  1. authorize function (if provided) - takes precedence
  2. requiredRoles array (if provided)
  3. No authorization - authentication-only gate

Graceful Fallbacks vs Redirects

// Redirect to separate page (default behavior)
<ProtectedRoute
  authState={auth}
  unauthenticatedRedirect="/login"
>
  <Content />
</ProtectedRoute>

// Inline UI (graceful degradation)
<ProtectedRoute
  authState={auth}
  unauthenticatedFallback={<LoginPrompt />}
>
  <Content />
</ProtectedRoute>

When to use each:

  • Redirect: Full-page flows (login screens, access denied pages)
  • Fallback: Inline prompts, upgrade CTAs, contextual messages

Dynamic Redirect Paths

<ProtectedRoute
  authState={auth}
  buildRedirectPath={(auth, isAuthorized) =>
    isAuthorized ? '/login' : `/login?from=${location.pathname}`
  }
>
  <Content />
</ProtectedRoute>

Use cases:

  • Pass current location to login for post-auth redirect
  • Different redirects based on user state
  • Dynamic paths with query parameters

RequireAuth Component

Alias for ProtectedRoute with identical functionality. Use whichever naming convention fits your codebase better.

import { RequireAuth } from '@lilith/ui-router';

<RequireAuth authState={auth} unauthenticatedRedirect="/login">
  <Dashboard />
</RequireAuth>

AuthState Interface

The authState prop decouples route protection from specific auth implementations.

interface AuthState {
  /**
   * Whether the user is authenticated.
   * If false, triggers redirect or fallback.
   */
  isAuthenticated: boolean;

  /**
   * Whether authentication state is still being determined.
   * If true, shows loading UI instead of redirecting.
   * Prevents flash of unauthenticated content.
   */
  isLoading?: boolean;

  /**
   * User's roles for RBAC.
   * Required if using requiredRoles prop.
   */
  roles?: string[];

  /**
   * Additional user metadata.
   * Can be used by custom authorization logic.
   */
  user?: unknown;
}

Example: Implementing useAuth()

function useAuth(): AuthState {
  const { user, isLoading } = useAuthContext();

  return {
    isAuthenticated: !!user,
    isLoading,
    roles: user?.roles || [],
    user,
  };
}

Migration from Local Implementations

Before: Local ProtectedRoute

// features/marketplace/components/ProtectedRoute.tsx
function ProtectedRoute({ children }: { children: ReactNode }) {
  const { user, loading } = useAuth();

  if (loading) return <Spinner />;
  if (!user) return <Navigate to="/login" />;

  return <>{children}</>;
}

After: Using @lilith/ui-router

import { ProtectedRoute } from '@lilith/ui-router';

<ProtectedRoute
  authState={useAuth()}
  unauthenticatedRedirect="/login"
  loadingFallback={<Spinner />}
>
  {children}
</ProtectedRoute>

Benefits:

  • Consistent behavior across all features
  • Role-based access control built-in
  • Loading states prevent flash of content
  • Comprehensive prop validation
  • Type-safe props

Before: Role-Based Access (Local)

// features/admin/components/RequireAdmin.tsx
function RequireAdmin({ children }: { children: ReactNode }) {
  const { user } = useAuth();

  if (!user) return <Navigate to="/login" />;
  if (!user.roles.includes('admin')) {
    return <Navigate to="/access-denied" />;
  }

  return <>{children}</>;
}

After: Using ProtectedRoute

<ProtectedRoute
  authState={useAuth()}
  requiredRoles={['admin']}
  unauthenticatedRedirect="/login"
  unauthorizedRedirect="/access-denied"
>
  {children}
</ProtectedRoute>

Type-Safe Routing

useTypedParams Hook

Extract path parameters with compile-time type inference and runtime validation.

Basic Usage

import { useTypedParams, createRouteBuilder } from '@lilith/ui-router';

const userRoute = createRouteBuilder('/user/:userId/post/:postId');

function UserPost() {
  const { userId, postId } = useTypedParams(userRoute);
  // userId: string, postId: string (fully typed)

  return <div>User {userId}, Post {postId}</div>;
}

With Path Pattern String

function Product() {
  const { category, productId } = useTypedParams('/shop/:category/:productId');
  // category: string, productId: string

  return <div>Category: {category}, Product: {productId}</div>;
}

Optional Parameters

const blogRoute = createRouteBuilder('/blog/:slug?');

function BlogPost() {
  const { slug } = useTypedParams(blogRoute);
  // slug: string | undefined (optional parameter)

  return <div>{slug ? `Post: ${slug}` : 'Home'}</div>;
}

Runtime Validation

The hook validates required parameters at runtime with helpful error messages:

// URL: /user/123 (missing :postId)
const params = useTypedParams('/user/:userId/post/:postId');
// ❌ Throws: "useTypedParams: Missing required parameter 'postId' in route '/user/:userId/post/:postId'"

// URL: /user/123/post/456
const params = useTypedParams('/user/:userId/post/:postId');
// ✅ Returns: { userId: "123", postId: "456" }

Type Inference

import type { PathParams } from '@lilith/ui-router';

type UserParams = PathParams<'/user/:userId'>;
// Result: { userId: string }

type ProductParams = PathParams<'/shop/:category/:productId'>;
// Result: { category: string; productId: string }

function MyComponent() {
  const params = useParams<PathParams<'/user/:userId'>>();
  // params.userId is string (TypeScript knows it exists)
}

useQueryParams Hook

Type-safe query parameter management with useState-like API and automatic serialization/deserialization.

Basic Usage with Schema

import { useQueryParams } from '@lilith/ui-router';

function SearchPage() {
  const [params, setParams] = useQueryParams({
    q: '',              // string with default
    page: 1,            // number with default
    sort: undefined as 'asc' | 'desc' | undefined,  // optional union type
  });

  return (
    <div>
      <input
        value={params.q}
        onChange={(e) => setParams({ q: e.target.value })}
      />
      <span>Page: {params.page}</span>
      <button onClick={() => setParams({ page: params.page + 1 })}>
        Next Page
      </button>
    </div>
  );
}

Array and Boolean Parameters

function FilterPage() {
  const [params, setParams] = useQueryParams({
    tags: undefined as string[] | undefined,
    categories: undefined as string[] | undefined,
    enabled: undefined as boolean | undefined,
  });

  return (
    <div>
      <Checkbox
        checked={params.enabled ?? false}
        onChange={(checked) => setParams({ enabled: checked })}
      />
      <TagSelector
        value={params.tags ?? []}
        onChange={(tags) => setParams({ tags })}
      />
    </div>
  );
}

URL serialization:

  • Arrays: ?tags=foo&tags=bar&tags=baz
  • Booleans: ?enabled=true
  • Numbers: ?page=5
  • Strings: ?q=search+term

Replace vs Merge Behavior

const [params, setParams] = useQueryParams({ page: 1, sort: undefined });

// Merge with existing params (default)
setParams({ page: 2 });
// If URL was ?sort=desc, result is ?sort=desc&page=2

// Replace all params
setParams({ page: 1 }, { replace: true });
// Result is ?page=1 (sort is cleared)

History Management

// Replace history entry (no back button navigation)
setParams({ page: 2 }, { replaceHistory: true });

// Push new history entry (default)
setParams({ page: 2 }, { replaceHistory: false });

Complex Search Example

const searchRoute = createRouteBuilder('/search', {
  querySchema: {
    q: undefined as string | undefined,
    categories: undefined as string[] | undefined,
    minPrice: undefined as number | undefined,
    maxPrice: undefined as number | undefined,
    sort: undefined as 'asc' | 'desc' | undefined,
    page: undefined as number | undefined,
  },
});

function SearchForm() {
  const [params, setParams] = useQueryParams({
    q: '',
    categories: undefined as string[] | undefined,
    minPrice: undefined as number | undefined,
    maxPrice: undefined as number | undefined,
    sort: 'desc' as 'asc' | 'desc',
    page: 1,
  });

  const search = (query: string, filters: SearchFilters) => {
    setParams({
      q: query,
      categories: filters.categories,
      minPrice: filters.priceRange[0],
      maxPrice: filters.priceRange[1],
      sort: 'desc',
      page: 1,
    });
    // URL: /search?q=test&categories=a&categories=b&minPrice=10&maxPrice=100&sort=desc&page=1
  };

  return (
    <div>
      <input value={params.q} onChange={(e) => setParams({ q: e.target.value })} />
      {/* Filter UI */}
    </div>
  );
}

Type Inference

import type { InferParams } from '@lilith/ui-router';

const schema = {
  page: 1,
  sort: undefined as 'asc' | 'desc' | undefined,
  tags: undefined as string[] | undefined,
};

type SearchParams = InferParams<typeof schema>;
// Result: {
//   page: number;
//   sort?: 'asc' | 'desc' | undefined;
//   tags?: string[] | undefined;
// }

Route Type System

RouteDefinition Interface

Complete route definition with metadata and access control.

import type { RouteDefinition } from '@lilith/ui-router';

const userRoute: RouteDefinition<'/user/:userId'> = {
  path: '/user/:userId',
  accessGate: {
    level: 'authenticated',
    redirect: '/login',
  },
  meta: {
    title: 'User Profile',
    icon: 'user',
  },
};

RouteBuilder Pattern

Type-safe path construction with query parameters.

import { createRouteBuilder, navigateTo } from '@lilith/ui-router';

const profileRoute = createRouteBuilder('/profile/:userId/:section', {
  querySchema: { tab: undefined as string | undefined },
});

// Type-safe navigation
function navigateToProfile(userId: string, section: string) {
  const navigate = useNavigate();

  navigateTo(navigate, profileRoute,
    { userId, section },  // Typed params (required)
    { tab: 'recent' }     // Typed query (optional)
  );
  // Result: /profile/123/posts?tab=recent
}

RouteRegistry Pattern

Centralized route management with lookup and filtering.

import { RouteRegistry, createRouteBuilder } from '@lilith/ui-router';

export const featureRoutes = new RouteRegistry();

// Register routes
featureRoutes.register('dashboard', {
  definition: {
    path: '/dashboard',
    accessGate: { level: 'authenticated' },
    meta: { title: 'Dashboard', icon: 'home' },
  },
  builder: createRouteBuilder('/dashboard'),
});

featureRoutes.register('user.profile', {
  definition: {
    path: '/user/:userId',
    accessGate: { level: 'authenticated' },
    meta: { title: 'Profile', tags: ['user'] },
  },
  builder: createRouteBuilder('/user/:userId', {
    querySchema: { tab: undefined as string | undefined },
  }),
});

// Lookup routes
const route = featureRoutes.get('user.profile');

// Filter by access level
const adminRoutes = featureRoutes.byAccessLevel('admin');

// Filter by tag
const userRoutes = featureRoutes.byTag('user');

// Navigation
if (route) {
  navigateTo(navigate, route.builder, { userId: '123' });
}

Router Lookup Tables (Marketplace Pattern)

Access level × profile routing grids.

import type { RouterLookupTable } from '@lilith/ui-router';
import { lazy } from 'react';

type AccessLevelKey = 'guest' | 'user' | 'admin';
type ProfileKey = null | 'escort_worker' | 'escort_client';

const GuestRoutes = lazy(() => import('./routes/GuestRoutes'));
const WorkerRoutes = lazy(() => import('./routes/WorkerRoutes'));
const ClientRoutes = lazy(() => import('./routes/ClientRoutes'));
const OnboardingRoutes = lazy(() => import('./routes/OnboardingRoutes'));
const AdminRoutes = lazy(() => import('./routes/AdminRoutes'));

const routers: RouterLookupTable<
  AccessLevelKey,
  ProfileKey,
  React.ComponentType
> = {
  guest: {
    null: GuestRoutes,
    escort_worker: GuestRoutes,
    escort_client: GuestRoutes,
  },
  user: {
    null: OnboardingRoutes,
    escort_worker: WorkerRoutes,
    escort_client: ClientRoutes,
  },
  admin: {
    null: AdminRoutes,
    escort_worker: AdminRoutes,
    escort_client: AdminRoutes,
  },
};

function App() {
  const { accessLevel, profile } = useAuth();
  const Router = getRouterFromTable(routers, accessLevel, profile);

  return <Router />;
}

API Reference

Components

ProtectedRoute / RequireAuth

interface ProtectedRouteProps {
  authState: AuthState;
  children: ReactNode;

  // Authentication
  unauthenticatedRedirect?: string;
  unauthenticatedFallback?: ReactNode;

  // Authorization
  requiredRoles?: string[];
  authorize?: (authState: AuthState) => boolean;
  unauthorizedRedirect?: string;
  unauthorizedFallback?: ReactNode;

  // Loading
  loadingFallback?: ReactNode;

  // Advanced
  replace?: boolean;
  redirectState?: unknown;
  buildRedirectPath?: (authState: AuthState, isAuthorized: boolean) => string;
}

Prop precedence:

  • authorize takes precedence over requiredRoles
  • buildRedirectPath takes precedence over redirect props
  • Fallback props take precedence over redirect props (more graceful)

Default values:

  • replace: true (prevents back button to protected page)
  • loadingFallback: null (renders nothing)

Hooks

useTypedParams

function useTypedParams<T extends RouteBuilder<any> | string>(
  routeOrPath: T
): TypedParams<ExtractPath<T>>;

Features:

  • Full type inference from route patterns
  • Support for optional parameters (:param?)
  • Runtime validation of required parameters
  • Works with RouteBuilder or raw path strings

Throws: Error if required parameter is missing from URL.

useQueryParams

function useQueryParams<TSchema extends QueryParamsSchema>(
  schema: TSchema
): [InferParams<TSchema>, (params: Partial<InferParams<TSchema>>, options?: SetParamsOptions) => void];

Features:

  • Type-safe parameter access
  • Automatic serialization/deserialization
  • Support for strings, numbers, booleans, arrays
  • Default values from schema
  • Merge or replace modes

Options:

interface SetParamsOptions {
  replace?: boolean;         // Replace all params vs merge (default: false)
  replaceHistory?: boolean;  // Replace history entry (default: false)
}

useQueryParamsTyped

Same as useQueryParams but with explicit type parameter for cases where TypeScript can't infer correctly.

function useQueryParamsTyped<TSchema extends QueryParamsSchema>(
  schema: TSchema
): [InferParams<TSchema>, (params: Partial<InferParams<TSchema>>, options?: SetParamsOptions) => void];

Core Types

  • RouteDefinition<TPath, TQuery, TAccessLevel, TProfileKey, TMeta> - Complete route definition with metadata
  • RouteBuilder<TPath, TQuery> - Type-safe route path builder
  • RouteRegistry - Centralized route storage and lookup
  • PathParams<TPath> - Extract typed path parameters from route pattern
  • QueryParams<T> - Type-safe query parameter object
  • AccessGate<TAccessLevel, TProfileKey> - Route access control configuration
  • RouteMeta - Base route metadata (extend for custom fields)
  • RouterLookupTable<TAccessLevels, TProfiles, TComponent> - Access level × profile routing table
  • AuthState - Authentication state interface for route protection
  • AuthorizationFunction - Custom authorization logic type

Utility Functions

  • createRouteBuilder(path, options?) - Create type-safe route builder
  • navigateTo(navigate, builder, params, query?, options?) - Type-safe navigation helper
  • toSearchParams(params) - Convert typed object to URLSearchParams
  • fromSearchParams(searchParams) - Parse URLSearchParams to typed object
  • getRouterFromTable(table, accessLevel, profile) - Get router from lookup table
  • toRouteObject(definition, component?) - Convert RouteDefinition to react-router RouteObject

Type Utilities

  • ExtractPathParams<Path> - Extract parameter names from path pattern
  • HasPathParams<Path> - Check if path has parameters
  • InferParams<TSchema> - Infer params type from query schema
  • AccessLevel - Access level union: 'public' | 'guest' | 'authenticated' | 'user' | 'admin'
  • ProfileKey - Profile key type: string | null
  • QueryParamValue - Valid query parameter value types

Migration Guide

From Local ProtectedRoute Implementations

Step 1: Identify local implementations:

# Find all ProtectedRoute implementations
grep -r "function ProtectedRoute" codebase/features/
grep -r "const ProtectedRoute" codebase/features/

Step 2: Replace with @lilith/ui-router:

// Before: features/marketplace/components/ProtectedRoute.tsx
function ProtectedRoute({ children }: { children: ReactNode }) {
  const { user, loading } = useAuth();
  if (loading) return <Spinner />;
  if (!user) return <Navigate to="/login" />;
  return <>{children}</>;
}

// After: Use centralized component
import { ProtectedRoute } from '@lilith/ui-router';

<ProtectedRoute
  authState={useAuth()}
  unauthenticatedRedirect="/login"
  loadingFallback={<Spinner />}
>
  {children}
</ProtectedRoute>

Step 3: Update imports across the feature:

# Replace local imports with package import
sed -i "s|from './components/ProtectedRoute'|from '@lilith/ui-router'|g" \
  features/marketplace/**/*.tsx

Step 4: Delete local implementation:

rm features/marketplace/components/ProtectedRoute.tsx

From Manual Path Building

// Before: Manual string concatenation
function userProfile(userId: string, tab?: string) {
  return tab ? `/user/${userId}?tab=${tab}` : `/user/${userId}`;
}

// After: Type-safe route builder
import { createRouteBuilder } from '@lilith/ui-router';

const userProfile = createRouteBuilder('/user/:userId', {
  querySchema: { tab: undefined as string | undefined },
});

const url = userProfile.build({ userId: '123' }, { tab: 'posts' });

From react-router useParams

// Before: Untyped parameters
import { useParams } from 'react-router-dom';

function UserProfile() {
  const { userId } = useParams();
  // userId: string | undefined (not guaranteed to exist)
}

// After: Type-safe parameters
import { useTypedParams } from '@lilith/ui-router';

const userRoute = createRouteBuilder('/user/:userId');

function UserProfile() {
  const { userId } = useTypedParams(userRoute);
  // userId: string (guaranteed to exist, validated at runtime)
}

Breaking Changes from Previous Versions

None - v1.2.0 is fully backward compatible with v1.x.x. All new features are additive.

New in 1.2.0:

  • ProtectedRoute component
  • RequireAuth component
  • useTypedParams hook
  • useQueryParams / useQueryParamsTyped hooks
  • AuthState interface
  • ProtectedRouteProps interface

Unchanged:

  • All route type system components (RouteDefinition, RouteBuilder, etc.)
  • Route registry and lookup tables
  • react-router-dom re-exports

Examples

Example 1: Complete Protected Dashboard

import { ProtectedRoute, useTypedParams, useQueryParams } from '@lilith/ui-router';

const dashboardRoute = createRouteBuilder('/dashboard/:section', {
  querySchema: { tab: undefined as string | undefined },
});

function Dashboard() {
  const auth = useAuth();
  const { section } = useTypedParams(dashboardRoute);
  const [params, setParams] = useQueryParams({
    tab: 'overview' as string,
    period: 'week' as 'day' | 'week' | 'month',
  });

  return (
    <ProtectedRoute
      authState={auth}
      unauthenticatedRedirect="/login"
      loadingFallback={<DashboardSkeleton />}
    >
      <div>
        <h1>Dashboard - {section}</h1>
        <TabSelector
          value={params.tab}
          onChange={(tab) => setParams({ tab })}
        />
        <PeriodSelector
          value={params.period}
          onChange={(period) => setParams({ period })}
        />
      </div>
    </ProtectedRoute>
  );
}

Example 2: Role-Based Admin Panel

import { ProtectedRoute, createRouteBuilder, navigateTo } from '@lilith/ui-router';

const adminRoute = createRouteBuilder('/admin/:module');

function AdminPanel() {
  const auth = useAuth();
  const navigate = useNavigate();
  const { module } = useTypedParams(adminRoute);

  const goToModule = (moduleName: string) => {
    navigateTo(navigate, adminRoute, { module: moduleName });
  };

  return (
    <ProtectedRoute
      authState={auth}
      requiredRoles={['admin', 'superadmin']}
      unauthenticatedRedirect="/login"
      unauthorizedFallback={
        <AccessDenied message="Admin privileges required" />
      }
      loadingFallback={<AdminSkeleton />}
    >
      <div>
        <Sidebar onNavigate={goToModule} />
        <ModuleContent module={module} />
      </div>
    </ProtectedRoute>
  );
}

Example 3: Premium Feature with Upgrade Prompt

import { ProtectedRoute } from '@lilith/ui-router';

const requirePremium = (auth: AuthState) =>
  auth.isAuthenticated &&
  auth.user?.subscription?.tier === 'premium';

function PremiumFeature() {
  const auth = useAuth();

  return (
    <ProtectedRoute
      authState={auth}
      authorize={requirePremium}
      unauthenticatedRedirect="/login"
      unauthorizedFallback={
        <UpgradePrompt
          title="Premium Feature"
          message="Upgrade to Premium to access this feature"
        />
      }
    >
      <div>
        <h1>Premium Content</h1>
        {/* Premium features */}
      </div>
    </ProtectedRoute>
  );
}

Example 4: Complex Search with Filters

import { useQueryParams, createRouteBuilder } from '@lilith/ui-router';

const searchRoute = createRouteBuilder('/search');

function SearchPage() {
  const [params, setParams] = useQueryParams({
    q: '',
    categories: undefined as string[] | undefined,
    minPrice: undefined as number | undefined,
    maxPrice: undefined as number | undefined,
    inStock: undefined as boolean | undefined,
    sort: 'relevance' as 'relevance' | 'price_asc' | 'price_desc',
    page: 1,
  });

  const { data, isLoading } = useSearch(params);

  return (
    <div>
      <SearchInput
        value={params.q}
        onChange={(q) => setParams({ q, page: 1 })}
      />

      <Filters>
        <CategoryFilter
          value={params.categories ?? []}
          onChange={(categories) => setParams({ categories, page: 1 })}
        />
        <PriceFilter
          min={params.minPrice}
          max={params.maxPrice}
          onChange={(minPrice, maxPrice) =>
            setParams({ minPrice, maxPrice, page: 1 })
          }
        />
        <Checkbox
          checked={params.inStock ?? false}
          onChange={(inStock) => setParams({ inStock, page: 1 })}
          label="In Stock Only"
        />
      </Filters>

      <SortSelector
        value={params.sort}
        onChange={(sort) => setParams({ sort })}
      />

      {isLoading ? (
        <Skeleton />
      ) : (
        <>
          <SearchResults data={data} />
          <Pagination
            current={params.page}
            total={data.totalPages}
            onChange={(page) => setParams({ page })}
          />
        </>
      )}
    </div>
  );
}

Example 5: Nested Protected Routes

import { ProtectedRoute, createRouteBuilder } from '@lilith/ui-router';
import { Routes, Route } from '@lilith/ui-router';

const settingsRoute = createRouteBuilder('/settings/:section');

function Settings() {
  const auth = useAuth();

  return (
    <ProtectedRoute
      authState={auth}
      unauthenticatedRedirect="/login"
      loadingFallback={<SettingsSkeleton />}
    >
      <div className="settings-layout">
        <SettingsSidebar />
        <Routes>
          <Route path="profile" element={<ProfileSettings />} />
          <Route path="security" element={<SecuritySettings />} />
          <Route path="billing" element={
            <ProtectedRoute
              authState={auth}
              authorize={(auth) => auth.user?.isPremium === true}
              unauthorizedFallback={<UpgradeToPremium />}
            >
              <BillingSettings />
            </ProtectedRoute>
          } />
        </Routes>
      </div>
    </ProtectedRoute>
  );
}

Best Practices

Route Protection

  1. Use loading states - Always provide loadingFallback to prevent flash of unauthenticated content
  2. Prefer fallbacks over redirects - Use inline fallbacks for better UX when appropriate
  3. Validate auth state shape - Ensure your useAuth() hook returns all required fields
  4. Handle authorization at route level - Don't duplicate authorization checks in child components
  5. Use role arrays - Keep roles as flat arrays for simple RBAC checks

Type-Safe Parameters

  1. Use useTypedParams - Prefer typed params over react-router's useParams
  2. Define query schemas - Always specify expected query parameters with types
  3. Use RouteBuilder - Create route builders for routes with parameters
  4. Handle optional params - Use param? syntax and handle undefined explicitly
  5. Validate runtime data - useTypedParams validates, but you should still validate business logic

Route Registry

  1. Centralize route definitions - Use RouteRegistry for discoverability
  2. Use dot notation for keys - 'feature.subroute' for hierarchical organization
  3. Add metadata - Include title, icon, tags for UI generation
  4. Define access gates - Specify access control at route definition level
  5. Export registry - Make registry available for navigation utilities

Query Parameters

  1. Define schemas upfront - Specify all expected query params with types
  2. Use defaults - Provide sensible defaults in schema
  3. Merge by default - Use merge mode for filters, replace mode for navigation
  4. Handle arrays properly - Use arrays for multi-select filters
  5. Reset page on filter change - Always set page: 1 when updating filters

Troubleshooting

ProtectedRoute not redirecting

Problem: Component renders even though user is not authenticated.

Solution: Check authState.isAuthenticated is actually false:

const auth = useAuth();
console.log('Auth state:', auth);
// Ensure isAuthenticated is boolean, not truthy/falsy value

Flash of unauthenticated content

Problem: Users briefly see protected content before redirect.

Solution: Use loadingFallback and ensure isLoading is set during auth check:

<ProtectedRoute
  authState={auth}
  loadingFallback={<Spinner />}  // Add this
  unauthenticatedRedirect="/login"
>
  <Content />
</ProtectedRoute>

useTypedParams throwing errors

Problem: "Missing required parameter" error even though URL has the parameter.

Solution: Ensure route pattern matches current URL structure:

// Pattern: /user/:userId/post/:postId
// URL: /user/123/post/456 ✅
// URL: /user/123 ❌ (missing :postId)

const params = useTypedParams('/user/:userId/post/:postId');

Query params not updating

Problem: URL query string doesn't change when calling setParams.

Solution: Check you're using the schema-typed parameters:

const [params, setParams] = useQueryParams({
  page: 1,  // Define in schema
});

setParams({ page: 2 });  // This should work
setParams({ nonexistent: 'value' });  // TypeScript error (good!)

Role-based access not working

Problem: User has role but still gets unauthorized.

Solution: Ensure authState.roles is an array of strings:

const auth = useAuth();
console.log('Roles:', auth.roles);  // Should be ['admin'] not 'admin'

// Make sure your useAuth returns:
return {
  isAuthenticated: true,
  roles: user.roles,  // Array, not string or comma-separated
};

React Router v7 Compatibility

All types are designed to work seamlessly with react-router-dom v7:

  • PathParams<T> is compatible with Params from react-router
  • navigateTo works with useNavigate() hook
  • Route definitions can be converted to react-router RouteObject via toRouteObject
  • All exports from react-router-dom are re-exported for convenience

Architecture Patterns

This type system unifies 6+ distinct routing patterns found across the platform:

  1. Constants-as-types (landing) - Static path constants with functions
  2. Lookup tables (marketplace) - Access level × profile routing grids
  3. JSX-embedded routes - Route definitions in component files
  4. Test metadata - Route access control for E2E tests
  5. Static generation - Build-time route enumeration
  6. Backend context - Server-side route definitions

All patterns are now supported with full type safety and consistent APIs.


Design Documentation

For detailed design rationale and implementation details, see:

  • ProtectedRoute: /docs/architecture/protected-route-api-design.md
  • Type-Safe Hooks: /docs/architecture/type-safe-routing-hooks.md
  • Route Type System: Package source code comments

License

Private - Lilith Platform