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
- ERP app — everything from
/dashboardto/inventory/*sits insideProtectedRoute(login required, redirects to/logincarryingstate.from) andAppLayout(sidebar + header shell). Most pages are additionally nested underRequirePermission— see auth-and-permissions.md for guard semantics, including the deliberate/billing/successexception. - Public marketing site —
PublicLayout(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 inpages/recruitment/because they feed the recruitment module, but they are routed publicly. - Customer portal —
PortalLayoutwraps both the portal's own login/register pages and, insidePortalProtectedRoute, 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
- Create the page component under
frontend/src/pages/<domain>/MyPage.tsx(default export —React.lazyrequires it). - Add a path constant to
constants/routes.ts. - Register the route in
App.tsx: add aconst MyPage = lazy(() => import('./pages/<domain>/MyPage'))and a<Route path={ROUTES.MY_PAGE} element={page(MyPage)} />under the correct layout (ERP pages go insideProtectedRoute→AppLayout). - Gate it: wrap in
<Route element={<RequirePermission permission={PermissionConstants.…} />}>— pass an array for OR semantics. Permission strings must mirror the backend's@PreAuthorizeconstants viaconstants/permissions.ts. - Add navigation: add a
NavItem(icon, label,to,permission, optionalroleContext) toui/layout/nav-config.tsso the sidebar shows it to the right users. - 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.