| ← Back to Frontend Documentation | Main Documentation |
Frontend Routing System
Created: Tue 29 Jul 2025 07:33:22 CEST
Document Version: 1.0 - Initial routing architecture and patterns
Security Classification: Internal Technical Documentation
Target Audience: Frontend Developers, Technical Leads, DevOps Engineers
Author: Paul Wisén
Overview
The Plings frontend uses React Router v6 for client-side routing, providing a single-page application experience with protected routes, lazy loading, and dynamic navigation based on user permissions and organization context.
Route Architecture
1. Route Hierarchy
/ # Public landing page
├── /auth/
│ ├── /login # User authentication
│ ├── /register # User registration
│ ├── /reset-password # Password reset
│ └── /verify-email # Email verification
├── /dashboard # Main application dashboard
├── /objects/
│ ├── / # Object list (default view)
│ ├── /create # Create new object
│ ├── /:id # Object detail view
│ ├── /:id/edit # Edit object
│ └── /:id/history # Object history
├── /spatial/
│ ├── / # Spatial dashboard overview
│ └── /:containerId # Specific container view
├── /organizations/
│ ├── / # Organization list
│ ├── /create # Create organization
│ ├── /:id # Organization dashboard
│ ├── /:id/settings # Organization settings
│ └── /:id/members # Member management
├── /admin/ # Super admin console
│ ├── /users # User management
│ ├── /organizations # Organization management
│ └── /system # System monitoring
├── /profile/ # User profile settings
├── /settings/ # Application settings
├── /scan # QR/NFC scanner interface
├── /public/:instanceKey # Public object view (no auth)
└── /404 # Not found page
2. Router Configuration
// src/App.tsx - Main router setup
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Suspense, lazy } from 'react';
import { AuthProvider } from '@/contexts/AuthContext';
import { OrganizationProvider } from '@/contexts/OrganizationContext';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { AdminRoute } from '@/components/auth/AdminRoute';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
// Lazy load page components
const Dashboard = lazy(() => import('@/pages/Dashboard'));
const ObjectList = lazy(() => import('@/pages/objects/ObjectList'));
const ObjectDetail = lazy(() => import('@/pages/objects/ObjectDetail'));
const SpatialDashboard = lazy(() => import('@/pages/spatial/SpatialDashboard'));
const AdminConsole = lazy(() => import('@/pages/admin/AdminConsole'));
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<OrganizationProvider>
<Routes>
{/* Public routes */}
<Route path="/" element={<LandingPage />} />
<Route path="/auth/*" element={<AuthRoutes />} />
<Route path="/public/:instanceKey" element={<PublicObjectView />} />
{/* Protected application routes */}
<Route path="/dashboard" element={
<ProtectedRoute>
<Suspense fallback={<LoadingSpinner />}>
<Dashboard />
</Suspense>
</ProtectedRoute>
} />
{/* Object management routes */}
<Route path="/objects" element={<ProtectedRoute />}>
<Route index element={
<Suspense fallback={<LoadingSpinner />}>
<ObjectList />
</Suspense>
} />
<Route path="create" element={<CreateObjectPage />} />
<Route path=":id" element={<ObjectDetail />} />
<Route path=":id/edit" element={<EditObjectPage />} />
</Route>
{/* Spatial organization routes */}
<Route path="/spatial" element={<ProtectedRoute />}>
<Route index element={<SpatialDashboard />} />
<Route path=":containerId" element={<ContainerView />} />
</Route>
{/* Organization management routes */}
<Route path="/organizations" element={<ProtectedRoute />}>
<Route index element={<OrganizationList />} />
<Route path="create" element={<CreateOrganizationPage />} />
<Route path=":id/*" element={<OrganizationRoutes />} />
</Route>
{/* Admin routes */}
<Route path="/admin/*" element={
<AdminRoute>
<Suspense fallback={<LoadingSpinner />}>
<AdminConsole />
</Suspense>
</AdminRoute>
} />
{/* User routes */}
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/scan" element={<ProtectedRoute><ScannerPage /></ProtectedRoute>} />
{/* Fallback routes */}
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</OrganizationProvider>
</AuthProvider>
</BrowserRouter>
);
}
Route Protection
1. Protected Route Component
// src/components/auth/ProtectedRoute.tsx
import { useAuth } from '@/contexts/AuthContext';
import { Navigate, useLocation } from 'react-router-dom';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: 'user' | 'admin' | 'super_admin';
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requiredRole = 'user'
}) => {
const { user, session, loading } = useAuth();
const location = useLocation();
if (loading) {
return <LoadingSpinner />;
}
if (!session || !user) {
// Redirect to login with return URL
return <Navigate to="/auth/login" state= replace />;
}
// Check role requirements
if (requiredRole === 'admin' && !['admin', 'super_admin'].includes(user.role)) {
return <Navigate to="/dashboard" replace />;
}
if (requiredRole === 'super_admin' && user.role !== 'super_admin') {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};
2. Organization Context Protection
// src/components/auth/OrganizationRoute.tsx
import { useOrganization } from '@/contexts/OrganizationContext';
import { Navigate } from 'react-router-dom';
interface OrganizationRouteProps {
children: React.ReactNode;
requiredPermission?: 'view' | 'edit' | 'admin';
}
export const OrganizationRoute: React.FC<OrganizationRouteProps> = ({
children,
requiredPermission = 'view'
}) => {
const { currentOrganization, userRole, loading } = useOrganization();
if (loading) {
return <LoadingSpinner />;
}
if (!currentOrganization) {
return <Navigate to="/organizations" replace />;
}
// Check organization permissions
const hasPermission = checkOrganizationPermission(userRole, requiredPermission);
if (!hasPermission) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};
function checkOrganizationPermission(
userRole: string,
requiredPermission: string
): boolean {
const permissions = {
view: ['member', 'admin', 'owner'],
edit: ['admin', 'owner'],
admin: ['owner']
};
return permissions[requiredPermission]?.includes(userRole) ?? false;
}
Dynamic Navigation
1. Context-Aware Navigation Menu
// src/components/layout/NavigationMenu.tsx
import { useAuth } from '@/contexts/AuthContext';
import { useOrganization } from '@/contexts/OrganizationContext';
import { useLocation, Link } from 'react-router-dom';
export const NavigationMenu: React.FC = () => {
const { user } = useAuth();
const { currentOrganization, userRole } = useOrganization();
const location = useLocation();
const getNavigationItems = () => {
const baseItems = [
{ path: '/dashboard', label: 'Dashboard', icon: 'home' },
{ path: '/objects', label: 'Objects', icon: 'package' },
{ path: '/spatial', label: 'Spatial', icon: 'move' },
];
// Add organization-specific items
if (currentOrganization) {
baseItems.push({
path: `/organizations/${currentOrganization.id}`,
label: 'Organization',
icon: 'building'
});
// Admin features
if (['admin', 'owner'].includes(userRole)) {
baseItems.push({
path: `/organizations/${currentOrganization.id}/settings`,
label: 'Settings',
icon: 'settings'
});
}
}
// Super admin features
if (user?.role === 'super_admin') {
baseItems.push({
path: '/admin',
label: 'Admin',
icon: 'shield'
});
}
return baseItems;
};
const navigationItems = getNavigationItems();
return (
<nav className="space-y-2">
{navigationItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={cn(
"flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium",
location.pathname.startsWith(item.path)
? "bg-blue-100 text-blue-700"
: "text-gray-600 hover:bg-gray-100"
)}
>
<Icon name={item.icon} size={16} />
<span>{item.label}</span>
</Link>
))}
</nav>
);
};
2. Breadcrumb Navigation
// src/components/layout/Breadcrumbs.tsx
import { useLocation, Link } from 'react-router-dom';
import { useBreadcrumbs } from '@/hooks/useBreadcrumbs';
export const Breadcrumbs: React.FC = () => {
const location = useLocation();
const breadcrumbs = useBreadcrumbs(location.pathname);
if (breadcrumbs.length <= 1) {
return null;
}
return (
<nav aria-label="Breadcrumb" className="flex items-center space-x-2 text-sm">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.path} className="flex items-center">
{index > 0 && (
<ChevronRightIcon className="w-4 h-4 text-gray-400 mx-2" />
)}
{index === breadcrumbs.length - 1 ? (
<span className="text-gray-900 font-medium">{crumb.label}</span>
) : (
<Link
to={crumb.path}
className="text-gray-500 hover:text-gray-700"
>
{crumb.label}
</Link>
)}
</div>
))}
</nav>
);
};
// Custom hook for generating breadcrumbs
export const useBreadcrumbs = (pathname: string) => {
const { currentOrganization } = useOrganization();
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
useEffect(() => {
const generateBreadcrumbs = (path: string): Breadcrumb[] => {
const segments = path.split('/').filter(Boolean);
const crumbs: Breadcrumb[] = [{ path: '/dashboard', label: 'Dashboard' }];
let currentPath = '';
for (const segment of segments) {
currentPath += `/${segment}`;
// Skip dashboard as it's already added
if (segment === 'dashboard') continue;
const label = getBreadcrumbLabel(segment, currentPath);
crumbs.push({ path: currentPath, label });
}
return crumbs;
};
setBreadcrumbs(generateBreadcrumbs(pathname));
}, [pathname, currentOrganization]);
return breadcrumbs;
};
Route Parameters and Query Handling
1. Route Parameter Extraction
// src/hooks/useRouteParams.ts
import { useParams, useSearchParams } from 'react-router-dom';
export const useObjectRouteParams = () => {
const { id } = useParams<{ id: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const filters = {
search: searchParams.get('search') || '',
classId: searchParams.get('classId') || '',
status: searchParams.get('status') || '',
sortBy: searchParams.get('sortBy') || 'name',
sortOrder: searchParams.get('sortOrder') as 'asc' | 'desc' || 'asc'
};
const updateFilters = (newFilters: Partial<typeof filters>) => {
const params = new URLSearchParams();
Object.entries({ ...filters, ...newFilters }).forEach(([key, value]) => {
if (value) {
params.set(key, value);
}
});
setSearchParams(params);
};
return {
objectId: id,
filters,
updateFilters
};
};
2. Query State Synchronization
// src/hooks/useQueryState.ts
import { useSearchParams } from 'react-router-dom';
import { useCallback } from 'react';
export const useQueryState = <T extends Record<string, any>>(
defaultState: T
) => {
const [searchParams, setSearchParams] = useSearchParams();
const state = useMemo(() => {
const queryState = { ...defaultState };
for (const [key, defaultValue] of Object.entries(defaultState)) {
const queryValue = searchParams.get(key);
if (queryValue !== null) {
// Type conversion based on default value type
if (typeof defaultValue === 'boolean') {
queryState[key] = queryValue === 'true';
} else if (typeof defaultValue === 'number') {
queryState[key] = parseInt(queryValue, 10);
} else {
queryState[key] = queryValue;
}
}
}
return queryState as T;
}, [searchParams, defaultState]);
const setState = useCallback((updates: Partial<T>) => {
const newParams = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
newParams.delete(key);
} else {
newParams.set(key, String(value));
}
});
setSearchParams(newParams);
}, [searchParams, setSearchParams]);
return [state, setState] as const;
};
// Usage example
const ObjectListPage: React.FC = () => {
const [filters, setFilters] = useQueryState({
search: '',
classId: '',
page: 1,
pageSize: 20
});
const { data: objects } = useQuery(GET_OBJECTS, {
variables: { filters }
});
return (
<div>
<SearchInput
value={filters.search}
onChange={(search) => setFilters({ search, page: 1 })}
/>
<ObjectGrid objects={objects} />
<Pagination
current={filters.page}
onChange={(page) => setFilters({ page })}
/>
</div>
);
};
Route-Based Code Splitting
1. Lazy Loading with Suspense
// src/pages/LazyRoutes.tsx
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
// Lazy load page components
const ObjectRoutes = lazy(() => import('./objects/ObjectRoutes'));
const SpatialRoutes = lazy(() => import('./spatial/SpatialRoutes'));
const OrganizationRoutes = lazy(() => import('./organizations/OrganizationRoutes'));
const AdminRoutes = lazy(() => import('./admin/AdminRoutes'));
export const LazyRoutes: React.FC = () => {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/objects/*" element={<ObjectRoutes />} />
<Route path="/spatial/*" element={<SpatialRoutes />} />
<Route path="/organizations/*" element={<OrganizationRoutes />} />
<Route path="/admin/*" element={<AdminRoutes />} />
</Routes>
</Suspense>
);
};
2. Route-Level Error Boundaries
// src/components/routing/RouteErrorBoundary.tsx
import { ErrorBoundary } from 'react-error-boundary';
import { useNavigate } from 'react-router-dom';
interface RouteErrorFallbackProps {
error: Error;
resetErrorBoundary: () => void;
}
const RouteErrorFallback: React.FC<RouteErrorFallbackProps> = ({
error,
resetErrorBoundary
}) => {
const navigate = useNavigate();
const handleGoHome = () => {
resetErrorBoundary();
navigate('/dashboard');
};
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold text-gray-900">
Something went wrong
</h1>
<p className="text-gray-600">
{error.message}
</p>
<div className="space-x-4">
<Button onClick={resetErrorBoundary}>
Try Again
</Button>
<Button variant="outline" onClick={handleGoHome}>
Go to Dashboard
</Button>
</div>
</div>
</div>
);
};
export const RouteErrorBoundary: React.FC<{ children: React.ReactNode }> = ({
children
}) => {
return (
<ErrorBoundary
FallbackComponent={RouteErrorFallback}
onError={(error, errorInfo) => {
console.error('Route error:', error, errorInfo);
// Log to monitoring service
}}
>
{children}
</ErrorBoundary>
);
};
Mobile-Specific Routing
1. Mobile Navigation Patterns
// src/components/layout/MobileNavigation.tsx
import { useState } from 'react';
import { useLocation, Link } from 'react-router-dom';
import { useMediaQuery } from '@/hooks/useMediaQuery';
export const MobileNavigation: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const location = useLocation();
const isMobile = useMediaQuery('(max-width: 768px)');
if (!isMobile) return null;
const navigationItems = [
{ path: '/dashboard', label: 'Dashboard', icon: 'home' },
{ path: '/objects', label: 'Objects', icon: 'package' },
{ path: '/spatial', label: 'Spatial', icon: 'move' },
{ path: '/scan', label: 'Scan', icon: 'camera' },
];
return (
<>
{/* Bottom tab navigation */}
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50">
<div className="flex justify-around">
{navigationItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={cn(
"flex flex-col items-center py-2 px-3 text-xs",
location.pathname.startsWith(item.path)
? "text-blue-600"
: "text-gray-600"
)}
>
<Icon name={item.icon} size={20} />
<span className="mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
{/* Add bottom padding to main content */}
<div className="pb-16">
{/* Main content */}
</div>
</>
);
};
2. PWA Route Handling
// src/hooks/useOfflineRouting.ts
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
export const useOfflineRouting = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [cachedRoutes, setCachedRoutes] = useState<string[]>([]);
const location = useLocation();
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
// Cache visited routes when online
if (isOnline && !cachedRoutes.includes(location.pathname)) {
setCachedRoutes(prev => [...prev, location.pathname]);
}
}, [location.pathname, isOnline, cachedRoutes]);
const isRouteCached = (path: string) => cachedRoutes.includes(path);
return {
isOnline,
isRouteCached,
cachedRoutes
};
};
Route Analytics
1. Route Tracking
// src/hooks/useRouteTracking.ts
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
export const useRouteTracking = () => {
const location = useLocation();
const { user } = useAuth();
useEffect(() => {
// Track page views
const trackPageView = () => {
// Send to analytics service
analytics.track('page_view', {
path: location.pathname,
search: location.search,
user_id: user?.id,
timestamp: new Date().toISOString()
});
};
trackPageView();
}, [location, user]);
};
// Usage in App.tsx
export default function App() {
useRouteTracking();
return (
<BrowserRouter>
{/* Router content */}
</BrowserRouter>
);
}
2. Performance Monitoring
// src/utils/routePerformance.ts
export const measureRoutePerformance = (routeName: string) => {
const startTime = performance.now();
return {
end: () => {
const duration = performance.now() - startTime;
// Log performance metrics
console.log(`Route ${routeName} loaded in ${duration.toFixed(2)}ms`);
// Send to monitoring service
if (duration > 1000) {
analytics.track('slow_route_load', {
route: routeName,
duration,
timestamp: new Date().toISOString()
});
}
}
};
};
// Usage in lazy-loaded components
const ObjectList = lazy(async () => {
const perf = measureRoutePerformance('ObjectList');
const component = await import('./ObjectList');
perf.end();
return component;
});
Testing Route Components
1. Route Testing Utilities
// src/test-utils/routeTestUtils.tsx
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from '@/contexts/AuthContext';
import { OrganizationProvider } from '@/contexts/OrganizationProvider';
export const renderWithRouter = (
component: React.ReactElement,
options: {
initialEntries?: string[];
user?: any;
organization?: any;
} = {}
) => {
const { initialEntries = ['/'], user, organization } = options;
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<MemoryRouter initialEntries={initialEntries}>
<AuthProvider initialUser={user}>
<OrganizationProvider initialOrganization={organization}>
{children}
</OrganizationProvider>
</AuthProvider>
</MemoryRouter>
);
return render(component, { wrapper: Wrapper });
};
2. Route Protection Tests
// src/components/auth/__tests__/ProtectedRoute.test.tsx
import { screen } from '@testing-library/react';
import { ProtectedRoute } from '../ProtectedRoute';
import { renderWithRouter } from '@/test-utils/routeTestUtils';
describe('ProtectedRoute', () => {
it('redirects to login when not authenticated', () => {
renderWithRouter(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>,
{ initialEntries: ['/dashboard'] }
);
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('renders children when authenticated', () => {
const mockUser = { id: '1', email: 'test@example.com', role: 'user' };
renderWithRouter(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>,
{
initialEntries: ['/dashboard'],
user: mockUser
}
);
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
});
Related Documentation
- Authentication - User authentication flows
- Component Architecture - Component structure
- State Management - Application state patterns
- Development Guidelines - Frontend coding standards
- Performance Optimization - Loading and caching strategies