Skip to main content

Routing

All routing lives in one file: frontend/src/App.tsx. There is no file-based routing and no route config objects — a single <BrowserRouter><Routes> tree using the unified react-router v7 package (the app imports from react-router, not react-router-dom).

The three route trees

  1. ERP app — everything from /dashboard to /inventory/* sits inside ProtectedRoute (login required, redirects to /login carrying state.from) and AppLayout (sidebar + header shell). Most pages are additionally nested under RequirePermission — see auth-and-permissions.md for guard semantics, including the deliberate /billing/success exception.
  2. Public marketing sitePublicLayout (header + footer, no auth): home, about, solutions, pricing, FAQ, privacy/terms, and the public careers pages (/careers, /careers/:id, /careers/:id/apply). The careers page components live in pages/recruitment/ because they feed the recruitment module, but they are routed publicly.
  3. Customer portalPortalLayout wraps both the portal's own login/register pages and, inside PortalProtectedRoute, the storefront (/portal, catalog, cart, wishlist, orders). The portal has a completely separate auth system (auth-and-permissions.md §Portal).

A catch-all <Route path="*"> renders the lazy NotFound page. Two convenience redirects exist: /crm/crm/dashboard and /inventory/inventory/dashboard.

Lazy loading and the page() helper

Every page is loaded with React.lazy(() => import(...)) so each route becomes its own chunk. (One historical exception: GovernmentForms is imported eagerly at the top of App.tsx.) Instead of one <Suspense> around the whole tree, each route element is produced by the page() helper:

const page = (Component: ComponentType) => (
<RouteErrorBoundary>
<Suspense fallback={<LoadingFallback />}>
<Component />
</Suspense>
</RouteErrorBoundary>
);

The per-route boundary is deliberate. As the comment in App.tsx explains: with React Router v7 + React.lazy, a single Suspense wrapping the whole <Routes> tree can leave the previous route's content on screen after the URL has already changed while the next chunk loads — a transition quirk of the combination. Giving each route its own boundary means navigation immediately swaps to the loading fallback and stays responsive. Each page also gets its own RouteErrorBoundary, so a crash in one page doesn't blank the shell.

Route constants

Paths are never string literals in components — they come from the ROUTES object in constants/routes.ts (ROUTES.EMPLOYEES, ROUTES.PAYROLL_RUN_DETAIL = '/payroll-runs/:id', …). Navigation uses the same constants, so renaming a path is a one-line change.

How to add a page

  1. Create the page component under frontend/src/pages/<domain>/MyPage.tsx (default export — React.lazy requires it).
  2. Add a path constant to constants/routes.ts.
  3. Register the route in App.tsx: add a const MyPage = lazy(() => import('./pages/<domain>/MyPage')) and a <Route path={ROUTES.MY_PAGE} element={page(MyPage)} /> under the correct layout (ERP pages go inside ProtectedRouteAppLayout).
  4. Gate it: wrap in <Route element={<RequirePermission permission={PermissionConstants.…} />}> — pass an array for OR semantics. Permission strings must mirror the backend's @PreAuthorize constants via constants/permissions.ts.
  5. Add navigation: add a NavItem (icon, label, to, permission, optional roleContext) to ui/layout/nav-config.ts so the sidebar shows it to the right users.
  6. If the page needs a brand-new permission, remember the frontend's static role map must be updated too — see the sync gotcha in auth-and-permissions.md.

For a worked full-stack example of an existing page, read ../learn/employees-walkthrough.md.