Routing
react-router v7 with a feature-module architecture — how routes are defined, composed, and added at Vesperia.
We use react-router v7 with createBrowserRouter. Routing is organized around feature modules: each domain exports a *Routes() function, and a single central src/router.ts composes them into the route tree. This is the Vesperia standard, as implemented in idxnet-web.
The old guide described a React Router SPA bootstrapped from a root.jsx entry with page.jsx files. That approach is retired. Routes are now data-defined objects composed from feature modules.
The shape of the tree
src/router.ts calls createBrowserRouter with a nested array of route objects. Layout shells nest from the outside in: a root shell, an auth layout, then the authenticated app shell. Feature routes plug into the app shell.
// src/router.ts
import { createBrowserRouter } from "react-router";
import { userRoutes } from "@/app/user";
import { holidayScheduleRoute } from "@/app/holiday-schedule/holiday-schedule.route";
import { AppFallback } from "@/components/app-fallback";
import { RouteErrorElement } from "@/components/error";
import { AppShell } from "@/components/layout/app-shell";
import { AuthLayout } from "@/components/layout/auth-layout";
import { RootShell } from "@/components/layout/root-shell";
const router = createBrowserRouter([
{
path: "/",
Component: RootShell,
ErrorBoundary: RouteErrorElement,
HydrateFallback: AppFallback,
children: [
{
Component: AuthLayout,
ErrorBoundary: RouteErrorElement,
HydrateFallback: AppFallback,
children: [
{
Component: AppShell,
ErrorBoundary: RouteErrorElement,
HydrateFallback: AppFallback,
children: [
...userRoutes(),
holidayScheduleRoute(),
// …every other feature module
{
path: "*",
lazy: {
Component: async () =>
(await import("./components/error")).ErrorNotFoundFallback,
},
},
],
},
],
},
],
},
]);
export { router };Note the consistent use of:
Componentto mount a route's element.ErrorBoundary(RouteErrorElement) so each shell isolates errors.HydrateFallback(AppFallback) for the loading state.- A trailing
{ path: "*" }catch-all that lazily renders the not-found fallback.
Feature modules own their routes
Every domain under src/app/<domain>/ exports a *Routes() function (re-exported from the module's index.ts). The function returns a RouteObject (or an array of them). src/router.ts imports and spreads/inlines them — it never defines paths inline for a feature.
// src/app/user-log/user-log.routes.ts
import type { RouteObject } from "react-router";
import { userLogConfig } from "@/app/user-log/user-log.config";
function userLogRoutes(): RouteObject {
return {
path: userLogConfig.rootPath,
lazy: {
Component: async () =>
(await import("./components/user-log-permission")).UserLoginPermission,
},
children: [
{
index: true,
lazy: {
Component: async () =>
(await import("./pages/index.page")).UserLogRootPage,
},
},
],
};
}
export { userLogRoutes };The route's base path comes from the module's *.config.ts (often derived from the central menu config), not hard-coded in two places.
Lazy loading
Page and wrapper components are code-split with route-level lazy. The lazy.Component form dynamically imports the module and returns the named export:
lazy: {
Component: async () => (await import("./pages/index.page")).UserLogRootPage,
}This keeps each feature out of the initial bundle until its route is visited.
Page file convention
Pages live in the module's pages/ directory, named by role:
pages/index.page.tsx— the list/root page ({ index: true })pages/new.page.tsx— a create pagepages/[param]/index.page.tsx— a dynamic detail page, where the directory name (e.g.[company-id]) documents the param
The dynamic segment is declared in the route with react-router's :param syntax:
// src/app/company-listing/company-listing.routes.ts
import type { RouteObject } from "react-router";
import { MENU_COMPANY_LISTING } from "@/config/menu.config";
import { fullAppPath } from "@/lib/full-app-path";
export function companyListingRoutes(): RouteObject {
return {
path: fullAppPath(MENU_COMPANY_LISTING.path),
children: [
{
index: true,
lazy: {
Component: async () =>
(await import("./pages/index.page")).CompanyListingRootPage,
},
},
{
path: ":seriesId/:type?",
lazy: {
Component: async () =>
(await import("./pages/[company-id]/index.page"))
.CompanyListingDetailsPage,
},
},
],
};
}The [company-id] directory mirrors the :seriesId param — the bracketed folder is the convention for "this directory holds a dynamic detail page."
Worked example: adding a new route
Create the feature module
Make src/app/payout/ and add a config with the base path (derived from the menu config) and a query-key factory:
// src/app/payout/payout.config.ts
import { MENU_PAYOUT } from "@/config/menu.config";
import { fullAppPath } from "@/lib/full-app-path";
const rootPath = fullAppPath(MENU_PAYOUT.path);
const payoutConfig = { rootPath };
export { payoutConfig };Add the pages
src/app/payout/pages/index.page.tsx // list → PayoutRootPage
src/app/payout/pages/new.page.tsx // create → PayoutNewPage
src/app/payout/pages/[payout-id]/index.page.tsx // detail → PayoutDetailsPageEach exports a named component, e.g. export function PayoutRootPage() { … }.
Define the routes function
// src/app/payout/payout.routes.ts
import type { RouteObject } from "react-router";
import { payoutConfig } from "@/app/payout/payout.config";
function payoutRoutes(): RouteObject {
return {
path: payoutConfig.rootPath,
children: [
{
index: true,
lazy: {
Component: async () =>
(await import("./pages/index.page")).PayoutRootPage,
},
},
{
path: "new",
lazy: {
Component: async () =>
(await import("./pages/new.page")).PayoutNewPage,
},
},
{
path: ":payoutId",
lazy: {
Component: async () =>
(await import("./pages/[payout-id]/index.page")).PayoutDetailsPage,
},
},
],
};
}
export { payoutRoutes };Re-export from the module index
// src/app/payout/index.ts
export * from "./payout.routes";Register in the central router
Add the import and call inside the app-shell children in src/router.ts:
import { payoutRoutes } from "@/app/payout";
// …inside the AppShell children:
payoutRoutes(),That's the whole flow: a self-contained module, a *Routes() function, one line wired into src/router.ts. Because everything is lazy, the new feature ships in its own chunk.