|
Some checks failed
Publish / publish (push) Failing after 0s
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| coverage | ||
| dist | ||
| node_modules | ||
| src | ||
| .gitignore | ||
| CLAUDE.md | ||
| eslint.config.js | ||
| package.json | ||
| README.md | ||
| REQUIRE_AUTH_EXAMPLES.md | ||
| ROUTING_TYPE_SYSTEM.md | ||
| tsconfig.json | ||
| tsup.config.ts | ||
| vitest.config.ts | ||
@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 gateRequireAuth- 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 inferenceuseQueryParams- Type-safe query string management with useState-like APIuseQueryParamsTyped- 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.rolesmust be provided if usingrequiredRoles- 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:
authorizefunction (if provided) - takes precedencerequiredRolesarray (if provided)- 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:
authorizetakes precedence overrequiredRolesbuildRedirectPathtakes 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 metadataRouteBuilder<TPath, TQuery>- Type-safe route path builderRouteRegistry- Centralized route storage and lookupPathParams<TPath>- Extract typed path parameters from route patternQueryParams<T>- Type-safe query parameter objectAccessGate<TAccessLevel, TProfileKey>- Route access control configurationRouteMeta- Base route metadata (extend for custom fields)RouterLookupTable<TAccessLevels, TProfiles, TComponent>- Access level × profile routing tableAuthState- Authentication state interface for route protectionAuthorizationFunction- Custom authorization logic type
Utility Functions
createRouteBuilder(path, options?)- Create type-safe route buildernavigateTo(navigate, builder, params, query?, options?)- Type-safe navigation helpertoSearchParams(params)- Convert typed object to URLSearchParamsfromSearchParams(searchParams)- Parse URLSearchParams to typed objectgetRouterFromTable(table, accessLevel, profile)- Get router from lookup tabletoRouteObject(definition, component?)- Convert RouteDefinition to react-router RouteObject
Type Utilities
ExtractPathParams<Path>- Extract parameter names from path patternHasPathParams<Path>- Check if path has parametersInferParams<TSchema>- Infer params type from query schemaAccessLevel- Access level union:'public' | 'guest' | 'authenticated' | 'user' | 'admin'ProfileKey- Profile key type:string | nullQueryParamValue- 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:
ProtectedRoutecomponentRequireAuthcomponentuseTypedParamshookuseQueryParams/useQueryParamsTypedhooksAuthStateinterfaceProtectedRoutePropsinterface
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
- Use loading states - Always provide
loadingFallbackto prevent flash of unauthenticated content - Prefer fallbacks over redirects - Use inline fallbacks for better UX when appropriate
- Validate auth state shape - Ensure your
useAuth()hook returns all required fields - Handle authorization at route level - Don't duplicate authorization checks in child components
- Use role arrays - Keep roles as flat arrays for simple RBAC checks
Type-Safe Parameters
- Use useTypedParams - Prefer typed params over react-router's useParams
- Define query schemas - Always specify expected query parameters with types
- Use RouteBuilder - Create route builders for routes with parameters
- Handle optional params - Use
param?syntax and handle undefined explicitly - Validate runtime data - useTypedParams validates, but you should still validate business logic
Route Registry
- Centralize route definitions - Use RouteRegistry for discoverability
- Use dot notation for keys -
'feature.subroute'for hierarchical organization - Add metadata - Include title, icon, tags for UI generation
- Define access gates - Specify access control at route definition level
- Export registry - Make registry available for navigation utilities
Query Parameters
- Define schemas upfront - Specify all expected query params with types
- Use defaults - Provide sensible defaults in schema
- Merge by default - Use merge mode for filters, replace mode for navigation
- Handle arrays properly - Use arrays for multi-select filters
- Reset page on filter change - Always set
page: 1when 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 withParamsfrom react-routernavigateToworks withuseNavigate()hook- Route definitions can be converted to react-router
RouteObjectviatoRouteObject - 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:
- Constants-as-types (landing) - Static path constants with functions
- Lookup tables (marketplace) - Access level × profile routing grids
- JSX-embedded routes - Route definitions in component files
- Test metadata - Route access control for E2E tests
- Static generation - Build-time route enumeration
- 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