← 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();
  });
});