SE Hub
Front End Development

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:

  • Component to 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 page
  • pages/[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 → PayoutDetailsPage

Each 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.

On this page