Anatomy of a Module
The ModuleDefinition contract every OGMF module exports — its members, the satisfies authoring pattern, and worked examples from the workshop and crm modules.
Introduction
An OGMF module is a self-contained feature folder under src/modules/{domain}/. Its public face to the rest of the app is a single object: a ModuleDefinition. The app discovers these objects at build time and assembles them into a registry that drives routing, the sidebar, and cross-module extension points.
Discovery is glob-based. At app startup the root collects every module's definition through Vite's import.meta.glob:
const discovered = {
...import.meta.glob<{ default: ModuleDefinition }>('@/modules/**/module.ts'),
...import.meta.glob<{ default: ModuleDefinition }>('@/addons/**/module.ts'),
};This is the constraint that the rest of this page hangs on: the file MUST be named module.ts, sit at the module root, and default-export a ModuleDefinition. A module that names the file something else, nests it deeper, or exports the definition under a named binding is invisible to the registry — it simply never loads.
The same glob pattern also picks up @/addons/**/module.ts. Addons author their definition exactly the way modules do; the only difference is the folder they live in. Everything on this page applies to both.
The ModuleDefinition type
ModuleDefinition is exported from @/core (re-exported there from @/core/types). It has four members — one required, three optional:
| Member | Type | Required | Purpose |
|---|---|---|---|
id | string | yes | Unique identifier for this module. |
routes | RouteObject[] | no | Route definitions. Omit if the module has no pages of its own. |
sidebar | SidebarItem[] | no | Sidebar navigation items the module contributes. |
slots | SlotContribution[] | no | Contributions to other modules' extension points. |
RouteObject is React Router's own route type (imported from react-router), so a module's routes are ordinary route definitions — typically authored in a sibling routes.tsx and imported into module.ts. SidebarItem and SlotContribution are OGMF types, also re-exported from @/core.
Each optional member maps to a sibling page:
| Member | Documented in |
|---|---|
routes | Routing |
sidebar | Navigation |
slots | Slots |
SidebarItem, briefly
A SidebarItem is a set of common fields intersected with one of two shapes — it is either a leaf (it navigates somewhere) or a branch (it groups children):
type SidebarItem = {
label: string;
icon?: ComponentType;
privilege?: string;
order?: number;
} & (
| { to: To; link?: boolean } // leaf — navigates
| { children: SidebarItem[] } // branch — groups
);The discriminator is structural: an item carrying to is a navigable leaf; an item carrying children is a group. privilege? gates visibility and order? controls position. The full filtering and ordering behavior — and what link? does — lives in Navigation; here we only need to recognize the two shapes when reading the examples below.
SlotContribution, briefly
SlotContribution names a target slot and the contribution payload to inject there. @/core co-exports a typed helper, slotContribution, for building one. Neither example module on this page contributes slots; the contribution pattern is covered in Slots.
The authoring pattern: satisfies ModuleDefinition
Every module's module.ts follows the same skeleton — a default export typed with satisfies, never with a type annotation:
import type { ModuleDefinition } from "@/core";
import { routes } from './routes';
import { CarIcon } from "lucide-react";
export default {
id: 'workshop',
routes,
sidebar: [ /* ... */ ],
} satisfies ModuleDefinition;Why satisfies ModuleDefinition and not : ModuleDefinition?
- A type annotation (
const m: ModuleDefinition = {...}) widens the value to the declared type. The literal's precise inferred shape is thrown away —idbecomesstring,sidebarbecomesSidebarItem[], and the specific labels and route arrays you wrote are no longer visible to the type system downstream. satisfieschecks the literal againstModuleDefinition— so a typo'd member, a missingid, or a malformedSidebarItemis still a compile error — while preserving the narrow inferred type of the literal. You get the shape guarantee without the widening.
For an anatomy object that other tooling may read, keeping the narrow type is the right default. Use satisfies for every module.ts.
Minimal example — the workshop module
The workshop module is the floor: an id, a routes array imported from its routes.tsx, and a single sidebar group with one leaf child.
import type { ModuleDefinition } from "@/core";
import { routes } from './routes';
import { CarIcon } from "lucide-react";
export default {
id: 'workshop',
routes,
sidebar: [
{
label: 'workshop',
icon: CarIcon,
children: [
{ label: 'agenda', to: '/workshop/agenda' },
],
},
],
} satisfies ModuleDefinition;Reading it member by member:
id: 'workshop'— the required unique identifier.routes— aRouteObject[]defined inworkshop/routes.tsxand imported here. The shape of those route objects (lazy views,authorizationMiddleware, locale loaders) is covered in Routing.sidebar— one branch item (label+icon+children) wrapping one leaf item (label+to). Noprivilegeand noorderhere: this module does not gate the entry or fix its position.
This is a complete, valid module. Everything beyond it is additive.
Rich example — the crm module
The crm module exercises the full SidebarItem shape: a top-level branch with order and privilege, nested branches, leaves with per-item privilege, and a deliberately large order to sink the settings group to the bottom. The structure is otherwise the same satisfies ModuleDefinition skeleton.
import type { ModuleDefinition } from "@/core";
import { routes } from './routes';
import { ContactRound } from "lucide-react";
export default {
id: 'crm',
routes,
sidebar: [
{
label: 'crm',
icon: ContactRound,
order: 20,
privilege: 'crm',
children: [
{ label: 'painelresumo', to: '/crm/dashboard', privilege: 'crm:painelresumo', order: 10 },
{ label: 'agenda', to: '/crm/agenda', privilege: 'crm:agenda', order: 20 },
{
label: 'pipeline',
order: 30,
children: [
{ label: 'leads', to: '/crm/leads', privilege: 'crm:leads' },
{ label: 'oportunidadesnegocios', to: '/crm/opportunities', privilege: 'crm:oportunidadesnegocios' },
{ label: 'ocupacao', to: '/crm/occupancy', privilege: 'crm:ocupacao' },
],
},
{
label: 'atividade',
order: 40,
children: [
{ label: 'marcacoes', to: '/crm/appointments', privilege: 'crm:marcacoes' },
{ label: 'tarefas', to: '/crm/tasks', privilege: 'crm:tarefas' },
{ label: 'communications', to: '/crm/communications', privilege: 'crm:communications' },
{ label: 'messages', to: '/messages', privilege: 'crm:messages' },
],
},
{ label: 'valesoferta', to: '/crm/gift-vouchers', privilege: 'crm:valesoferta', order: 50 },
{
label: 'apoio',
privilege: 'crm:apoio',
order: 999,
children: [
{ label: 'campanhas', to: '/crm/settings/campaigns', privilege: 'crm:apoio:campanhas' },
{ label: 'equipas', to: '/crm/settings/teams', privilege: 'crm:apoio:equipas' },
/* ...further settings leaves... */
],
},
],
},
],
} satisfies ModuleDefinition;What this shows that the minimal example does not:
- Top-level gating and ordering. The root
crmbranch carriesprivilege: 'crm'andorder: 20. - Arbitrary nesting.
pipeline,atividade, andapoioare branches inside a branch —childrenisSidebarItem[], so the tree is recursive to any depth. - Hierarchical privileges. Leaves use colon-scoped privilege strings (
crm:leads,crm:apoio:campanhas) that mirror the menu nesting. - Cross-module links. The
messagesleaf points at/messages, a route owned elsewhere — a sidebar leaf'stois just a route target, not necessarily a route this module declares. - Deliberate placement.
apoiosetsorder: 999to sit last regardless of declaration order.
Both modules are the same object with the same satisfies discipline; crm just populates more of the SidebarItem surface. The semantics of how privilege filters and order sorts the assembled tree are detailed in Navigation.
Where to go next
Module System
How OGMF features are packaged as self-contained modules, discovered at runtime, filtered by license, and merged into a single registry that drives routing, navigation, and slot-based extension.
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.