Navigation
How modules declare sidebar navigation, how the registry merges and orders it, and how privileges gate what each user sees.
Introduction
Every module declares its own slice of the application sidebar through the sidebar field on its ModuleDefinition. The registry collects those slices from all licensed modules, merges them into one tree, and orders the top level. The sidebar renderer then filters the tree by the current user's privileges and orders every level before painting it.
This split is deliberate. A module owns what it contributes to the navigation — its labels, icons, destinations, and the privileges that guard them. The registry owns assembly — concatenating every module's contribution into a single array. The renderer owns presentation — privilege filtering, recursive ordering, active-trail highlighting, and search. No module knows about any other module's navigation, and none of them reach into the rendering layer.
Navigation sits alongside routes and slots on the same ModuleDefinition. See Anatomy for where sidebar fits in the full module definition shape.
The SidebarItem type
A sidebar node is a discriminated shape defined in @/core/types: a set of common fields intersected with either a destination or a list of children.
export type SidebarItem = {
label: string;
icon?: ComponentType;
privilege?: string;
order?: number;
} & ({
to: To;
link?: boolean;
} | {
children: SidebarItem[];
});The common fields apply to every node, leaf or group:
| Field | Type | Purpose |
|---|---|---|
label | string | An i18n key, not display text. The renderer runs it through t(item.label) before painting. |
icon | ComponentType (optional) | A React component rendered as the node's icon. Modules pass a lucide-react icon component here. |
privilege | string (optional) | A privilege string that gates the node's visibility. Omit for an always-visible node. |
order | number (optional) | Sort key among siblings. Defaults to 50 when omitted. |
A node then takes exactly one of two branches:
| Branch | Fields | Meaning |
|---|---|---|
| Leaf link | to: To, link?: boolean | A navigable destination. to is a react-router To (a path string or a location object). |
| Group | children: SidebarItem[] | A collapsible group whose entries are themselves SidebarItems (recursively). |
A node is either a destination or a group, never both. This is the intended authoring contract: give a node a to to make it a clickable leaf, or give it children to make it a collapsible group. Do not put both on the same node.
link?: boolean is part of the leaf shape in the type, but the current sidebar renderer does not read it — every leaf renders as a react-router <Link to={item.to}>. Treat it as reserved; do not depend on any visual behavior from it today.
How the registry assembles navigation
createRegistry in @/core/registry builds the merged tree. Its job for navigation is narrow and worth stating precisely, because the rest of the behavior lives in the renderer.
Filter by license
createRegistry receives the discovered modules and the licensedModules id list. It imports only modules whose id is in the license set; an unlicensed module's sidebar never enters the merge. This is a module-level filter by id — it is not privilege filtering.
Concatenate every module's sidebar
For each licensed module that defines sidebar, the registry spreads its items into one flat accumulator:
if (definition.sidebar) {
allSidebar.push(...definition.sidebar);
}Children are carried along untouched inside each top-level item — the registry never descends into them.
Sort the top level by order
The accumulated array is sorted once, at the top level only, with 50 as the default when order is omitted:
const sidebar = allSidebar.sort((a, b) => (a.order ?? 50) - (b.order ?? 50));The result is stored on ModuleRegistry.sidebar.
createRegistry sorts only the top level and never reads privilege. The order values on nested children are authoring metadata that the registry does not apply, and privilege gating does not happen here. Both of those are the renderer's responsibility — see the next section.
How the renderer filters and orders
The consumer of useSidebar() is AppSidebar (@/components/app-sidebar). Its prepareItems helper walks the merged tree recursively and does three things the registry does not:
- Privilege gating. If a node has a
privilegeand the current user lacks it, the node is dropped. The check ispermissions.includes("all") || permissions.includes(privilege), sourced from the auth store. - Empty-group pruning. A group whose children all get filtered out is itself removed, so an empty collapsible never appears.
- Recursive ordering. Every level is sorted by
order ?? 50, so nestedchildrenare ordered too — at render time, not in the registry.
function prepareItems(items, hasPermission) {
return items
.map((item) => {
if (item.privilege && !hasPermission(item.privilege)) return null;
const base = { label: item.label, icon: item.icon, order: item.order ?? 50 };
if ("children" in item) {
const children = prepareItems(item.children, hasPermission);
if (children.length === 0) return null;
return { ...base, children };
}
return { ...base, to: toPath(item.to) };
})
.filter((item) => item !== null)
.sort((a, b) => a.order - b.order);
}Because ordering of nested children is applied by the renderer (not the registry), it is the module author's responsibility to assign sensible order values within each group. A child without order falls back to 50, exactly as the top level does. See Routing for how privilege strings relate to the authorizationMiddleware that guards the route a leaf points at.
Worked example: the crm module
The crm module declares a single top-level group that nests two levels deep — a group containing leaves and subgroups, and those subgroups containing their own leaves. It shows every field of SidebarItem in use.
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" },
{ label: "areasnegocio", to: "/crm/settings/business-areas", privilege: "crm:apoio:areasnegocio" },
{ label: "pipeline", to: "/crm/settings/pipeline", privilege: "crm:apoio:pipeline" },
{ label: "fases", to: "/crm/settings/phases", privilege: "crm:apoio:fases" },
{ label: "categorias", to: "/crm/settings/categories", privilege: "crm:apoio:categorias" },
{ label: "objectivos", to: "/crm/settings/goals", privilege: "crm:apoio:objectivos" },
{ label: "tipotarefa", to: "/crm/settings/task-types", privilege: "crm:apoio:tipotarefa" },
{ label: "abc", to: "/crm/settings/abc", privilege: "crm:apoio:abc" },
{ label: "sms", to: "/crm/settings/sms", privilege: "crm:apoio:sms" },
],
},
],
},
],
} satisfies ModuleDefinition;A few things to read out of this:
- The root is a group, not a link.
crmhaschildrenand noto, so it renders as a collapsible with theContactRoundlucide icon. Only the root carries an icon here; nested nodes omit it. ordercontrols sibling position at each level. Inside thecrmgroup, the renderer orderspainelresumo(10) →agenda(20) →pipeline(30) →atividade(40) →valesoferta(50) →apoio(999). The999onapoiois the idiom for "always last".- Nesting goes group → subgroup → leaf.
pipelineandatividadeare subgroups whose children are leaves withtodestinations. - Privileges form a hierarchy by convention.
crmgates the whole group;crm:agendagates one leaf;crm:apoio:campanhasgates a leaf two levels deep. If a user lackscrm, the renderer drops the entire group in one step. If they lack onlycrm:apoiobut have othercrm:*privileges, theapoiosubgroup disappears while the rest of CRM stays — and if every child of a subgroup is filtered out, that subgroup is pruned too. - The
pipelinechildren omitorder. They fall back to50each, so they keep declaration order relative to one another.
The useSidebar() hook
useSidebar() from @/core is the consumer API. It reads the registry from context and returns the merged, top-level-sorted SidebarItem[]:
export function useSidebar() {
return useRegistry().sidebar;
}The hook returns the same ModuleRegistry.sidebar array the registry produced — already merged across modules and sorted at the top level. It does not apply privilege filtering or recursive child ordering; a consumer that wants the painted tree (filtered and fully ordered) does that itself, the way AppSidebar does with prepareItems against the auth store's permissions.
import { useSidebar } from "@/core";
function MyNavConsumer() {
const items = useSidebar();
return items.map((item) => /* render, filtering by privilege yourself */);
}If the registry is still loading (or no modules are licensed), useSidebar() returns an empty array — the provider seeds context with an empty registry until createRegistry resolves.
Related
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.
Slots
Extension points that let any module contribute UI into a place another module owns, without the owner knowing who the contributors are.