Routing
How the module registry's routes are merged with the static shell, how dev routes bypass the license registry, and how each module authors its own routes.tsx with lazy views, authorization middleware, and route-level i18n loading.
Introduction
OGMF has no central route table. Each feature module owns a routes.tsx at its root, the registry merges those into a single moduleRoutes array during discovery, and buildRoutes wraps that merged array in a small, static shell — a top-level catch-all plus the AppLayout chrome and its error routes. The router is then created exactly once from the result.
This keeps routing distributed the same way the module system is: a module declares where it lives in the URL space, what permissions and licensing gate it, and which i18n namespace its subtree needs — without touching any shared file. The shell is the only fixed part, and it is deliberately tiny.
buildRoutes is called once, in AppRouter, from the routes the registry resolved: sentryCreateBrowserRouter(buildRoutes(moduleRoutes)), where moduleRoutes is useModuleRoutes(). The router is memoized on moduleRoutes, so the full tree is constructed a single time after the registry finishes loading. Where moduleRoutes comes from is covered in Discovery.
buildRoutes(moduleRoutes)
buildRoutes lives in @/routes and takes the merged registry routes, returning the complete RouteObject[] tree:
export function buildRoutes(moduleRoutes: RouteObject[]): RouteObject[] {
return [
{
path: '*',
Component: LayoutCatchAllRoute
},
{
Component: AppLayout,
children: [
...getDevRoutes(),
...moduleRoutes,
{
path: '/404',
lazy: () => Promise.resolve({Component: () => <GenericError code={404}/>}),
},
{
path: '/403',
lazy: () => Promise.resolve({Component: () => <GenericError code={403}/>}),
}
]
},
];
}The tree has exactly two top-level entries:
| Entry | Role |
|---|---|
{ path: '*', Component: LayoutCatchAllRoute } | The catch-all sibling. LayoutCatchAllRoute sets OG.isOGMFRoute = false and renders null — it does not draw any OGMF chrome. A URL that matches no module route falls through here and is yielded to the host application. |
{ Component: AppLayout, children: [...] } | The application shell. AppLayout sets OG.isOGMFRoute = true and renders the sidebar, header, and an <Outlet/> for the matched child. Everything OGMF owns is mounted inside this. |
Because the catch-all is a sibling of AppLayout rather than a child of it, an unmatched path never mounts the OGMF shell — it signals (OG.isOGMFRoute = false) that the path belongs to the host, instead of rendering an OGMF 404 page over the host's own UI. The shell's /404 and /403 routes are the in-app error destinations, reached by explicit redirects (see authorizationMiddleware), not by falling off the end of the route table.
The AppLayout children are concatenated in this order:
...getDevRoutes()
Dev-only routes, prepended so they mount inside the shell. Empty in production (see below).
...moduleRoutes
The merged routes contributed by every licensed module's routes.tsx, resolved by the registry.
/404 and /403
Two shell error routes. Each lazy-renders GenericError with the matching status code — <GenericError code={404}/> and <GenericError code={403}/> — wrapped in Promise.resolve so they satisfy the same lazy route contract as real views without an actual dynamic import.
getDevRoutes()
getDevRoutes returns the development-only routes that are mounted inside the shell:
function getDevRoutes(): RouteObject[] {
if (!import.meta.env.DEV) return [];
const devModules = import.meta.glob<{ default: RouteObject[] }>(
'@/modules/_development/routes.tsx',
{eager: true},
);
return Object.values(devModules).flatMap((mod) => mod.default);
}The guard if (!import.meta.env.DEV) return [] is the whole point. In a production build, import.meta.env.DEV is statically false, so the function reduces to return [] and Vite tree-shakes the import.meta.glob and everything it would pull in — @/modules/_development/routes.tsx and its views never enter the production bundle.
Two things make these routes distinct from ordinary module routes:
- They load outside the license registry.
moduleRoutescome from the registry, which only resolves modules the current license permits.getDevRoutesglobs@/modules/_development/routes.tsxdirectly, so dev routes (such as the form-builder playground) are available in development regardless of licensing — they are not gated byauthorizationMiddlewarethe way module routes are. - They are eager (
{ eager: true }). Because they are DEV-only and few, the glob resolves them synchronously at module-evaluation time rather than returning lazy importers.
Do not conflate this with the _development module injection. App.tsx injects _development's module.ts into the registry separately so its slot contributions flow through the normal pipeline; getDevRoutes only loads its routes.tsx, outside the registry. The two are different concerns — see Discovery for the registry injection.
Authoring a module's routes.tsx
A module exports a routes array typed with satisfies RouteObject[]. Using satisfies (rather than an annotation) keeps the literal types — paths, the discriminated shape of each route — narrow for the registry while still checking the array against React Router's RouteObject.
The workshop module is the minimal shape:
import type { RouteObject } from "react-router";
import { authorizationMiddleware } from "@/routes/authorizationMiddleware";
import { ModuleLocalesNamespaceLoader } from "@/routes/ModuleLocalesNamespaceLoader";
export const routes = [
{
path: 'oficinas',
element: <ModuleLocalesNamespaceLoader ns="workshop" />,
children: [
{
path: 'agenda',
middleware: [async (_, next) => await authorizationMiddleware(next, {
module: 'workshop',
})],
lazy: async () => {
const { default: Component } = await import("./views/AgendaView");
return { Component };
},
},
],
},
] satisfies RouteObject[];Three conventions are visible here, and they recur in every module.
Lazy-loaded views
Route-level views use the lazy route property with an async import, so each view is code-split into its own chunk and only fetched when its route is visited:
lazy: async () => {
const { default: Component } = await import("./views/AgendaView");
return { Component };
},The view is the file's default export, destructured and returned as Component. This is the standard form for any real view. (For a leaf route, eager element is used for placeholders instead — see the CRM contrast below.)
Route-level i18n namespace loading
The parent route uses element: <ModuleLocalesNamespaceLoader ns="..." /> to wrap the whole module subtree. ModuleLocalesNamespaceLoader is a tiny component:
export function ModuleLocalesNamespaceLoader({ns}: { ns: string }) {
const {i18n} = useTranslation();
useEffect(() => {
i18n.loadNamespaces(ns).catch(() => console.warn("Failed to load namespace", ns));
}, [i18n, ns]);
return (<Outlet/>);
}It calls i18n.loadNamespaces(ns) once on mount and then renders <Outlet/> so the matched child shows through. The effect is that a module's translation namespace is loaded lazily, at route level — only when a user navigates into that module's subtree — instead of being bundled into the initial i18n payload for every module. A load failure is logged and swallowed, so a missing namespace degrades to fallback strings rather than breaking the route.
Per-route authorization middleware
Each leaf route declares a middleware array whose entry wraps authorizationMiddleware:
middleware: [async (_, next) => await authorizationMiddleware(next, {
module: 'workshop',
})],Note the call shape. authorizationMiddleware(next, context) takes next first and the authorization context second — and the middleware entry is the wrapper async (_, next) => await authorizationMiddleware(next, { ... }), not authorizationMiddleware registered bare. The first middleware argument is unused here (_); the second is React Router's next.
The CRM module: gating helper and eager placeholders
The CRM module is larger and shows two further patterns. First, it factors the middleware wrapper into a small helper so every route gates on the same module plus a per-route permission:
const requireCrm = (permission: string): RouteObject['middleware'] => [
async (_, next) => await authorizationMiddleware(next, {permission, module: 'crm'}),
];Each route then reads middleware: requireCrm('crm:agenda:view'), passing only the permission string. This is the contrast with workshop: workshop gates on module alone, while CRM gates on module and a permission.
Second, CRM mixes lazy views with eager placeholders. Routes whose view is built use lazy with an async import; routes that are not built yet use element with an eager <ComingSoon .../>:
{
path: 'agenda',
middleware: requireCrm('crm:agenda:view'),
lazy: async () => {
const {default: Component} = await import("./views/AgendaView");
return {Component};
},
},
{path: 'leads', middleware: requireCrm('crm:leads'), element: <ComingSoon title="crm:leads"/>},| Property | When to use |
|---|---|
lazy: async () => { ... return { Component } } | A real, built view — code-split and fetched on navigation. |
element: <ComingSoon .../> | A stub for a route that exists (and is permission-gated) but is not implemented; rendered eagerly, no separate chunk. |
CRM also nests routes — a settings parent with its own children array — which inherit the same requireCrm(...) gating per leaf. Nesting works exactly as React Router defines it; OGMF adds nothing here.
authorizationMiddleware
The middleware is the single gate for module, permission, and addon access. It reads the auth store, then short-circuits with a redirect on the first failed check:
export async function authorizationMiddleware(
next: () => Promise<unknown>,
{permission, module, addon}: authorizationContextType,
): Promise<Response | unknown> {
const {useAuthStore} = await import('@/stores/authStore');
const {hasPermission, hasModule, hasAddon, isAuthenticated} = useAuthStore.getState();
if (!isAuthenticated) {
return redirect("/login");
}
if ((!!module && !hasModule(module)) || (!!addon && !hasAddon(addon))) {
return redirect("/404");
}
if (!!permission && !hasPermission(permission)) {
return redirect("/403");
}
return next();
}The context object has three optional fields; each is only checked when present:
| Field | Checked with | Failure redirect |
|---|---|---|
| (always) | isAuthenticated | redirect("/login") |
module | hasModule(module) | redirect("/404") |
addon | hasAddon(addon) | redirect("/404") |
permission | hasPermission(permission) | redirect("/403") |
The check order and the choice of redirect target are deliberate:
- Unauthenticated →
/login. Nothing else is evaluated. - Missing module or addon →
/404. An unlicensed module is made to look as if it does not exist. Returning404rather than403avoids disclosing that a module the customer is not licensed for is even part of the product. - Missing permission →
/403. Here the module is licensed and the user is authenticated, but lacks the specific privilege — an honest "forbidden".
/404 and /403 are the very shell routes buildRoutes mounts under AppLayout, so a redirect lands on a GenericError page rendered inside the normal app chrome. If every check passes, the middleware calls next() and the route resolves as usual.
useAuthStore is imported dynamically inside the middleware (await import('@/stores/authStore')) rather than at the top of the file. This keeps the store out of the router-construction module graph and reads the latest state at navigation time via useAuthStore.getState().
Related
Discovery & Licensing
How OGMF discovers module definitions at build time, filters them by the user's license, and assembles the runtime ModuleRegistry consumed through React context.
Navigation
How modules declare sidebar navigation, how the registry merges and orders it, and how privileges gate what each user sees.