# Navigation: typed labels, dynamic hrefs, meta, action `NavigationItem` has four optional generics. They all default to permissive shapes (or `never`, in the case of `TAction`), so existing apps keep compiling; opt in one at a time as you want stricter typing. ```ts interface NavigationItem< TLabel extends string = string, TContext = void, TMeta = unknown, TAction = never, > { readonly label: TLabel; readonly to: TContext extends void ? string : string | ((ctx: TContext) => string); readonly icon?: string | ComponentType<{ className?: string }>; readonly group?: string; readonly order?: number; readonly hidden?: boolean; readonly meta?: TMeta; readonly action?: TAction; } ``` `TAction` defaults to `never`, so the `action` field is absent from the item surface until an app opts in. `action` is how a nav item carries a dispatchable intent (start a journey, open a module as a tab, trigger a shell command) without overloading `meta`. The framework treats it as opaque — the shell's navbar renderer switches on `action.kind` and dispatches. See the `TAction` section below. ## Ordering `buildNavigationManifest` (and therefore `useNavigation`) sorts items with these rules, in order: - `order` is ascending — lower numbers render first. - Items without an explicit `order` render after every item that has one — there's no sentinel cap, so `order: 9999` still sorts before an unordered item. - Ties — including the common case where no item sets `order` — preserve insertion order: modules in the order they were registered with the registry, items in the order declared in each module's `navigation` array, plugin-contributed items (via `RegistryPlugin.contributeNavigation`) last. - Label is **not** a tiebreaker. Labels are typically i18n keys (especially when `TLabel` is narrowed to `ParseKeys`), so sorting on label would sort by key naming rather than anything user-meaningful. Practical guidance: set explicit `order` when you care about the position. Rely on registration order when modules collaborate to express intent ("project's nav comes before assets'"). Don't rely on label-alphabetical ordering — it isn't a thing. ## The typical pattern: one alias, used everywhere Declare the alias in your `app-shared` package so every module and the shell agree: ```ts // app-shared/src/nav.ts import type { NavigationItem } from "@modular-react/core"; import type { ParseKeys } from "i18next"; import type { Action } from "./permissions"; export interface NavContext { workspaceId: string; } export interface NavMeta { action?: Action; badge?: "beta" | "new"; analyticsId?: string; } export type AppNavItem = NavigationItem; ``` If the app wires nav-driven actions (start a journey, open a module as a tab, etc.), add the `TAction` generic to the alias: ```ts // App-owned union — shell's navbar dispatcher switches on `kind`. Plugins // (like the journeys plugin) publish suggested shapes that apps merge in. export type NavAction = | { kind: "open-module"; moduleId: string; entry: string; input?: unknown } | { kind: "journey-start"; journeyId: string; buildInput?: (ctx?: unknown) => unknown }; export type AppNavItem = NavigationItem; ``` Thread it through `defineModule`: ```ts // modules/portal/src/index.ts import { defineModule } from "@react-router-modules/core"; import type { AppDependencies, AppSlots, AppNavItem } from "@myorg/app-shared"; export default defineModule, AppNavItem>({ id: "portal", version: "1.0.0", navigation: [ { label: "appShell.nav.portalRequests", // ParseKeys — typo = compile error to: ({ workspaceId }) => `/portal/${workspaceId}/requests`, // typed context meta: { action: "managePortalRequests" }, // typed meta }, ], }); ``` And read with the same type on the shell: ```tsx // components/Sidebar.tsx import { useNavigation } from "@modular-react/react"; import { resolveNavHref } from "@modular-react/core"; import { useTranslation } from "react-i18next"; import type { AppNavItem } from "@myorg/app-shared"; import { usePermissions } from "./permissions"; import { useWorkspaceId } from "./workspace"; export function Sidebar() { const nav = useNavigation(); const { t } = useTranslation(); const { canPerform } = usePermissions(); const workspaceId = useWorkspaceId(); const visible = nav.items.filter((item) => !item.meta?.action || canPerform(item.meta.action)); return (
    {visible.map((item) => (
  • {t(item.label)} {item.meta?.badge && {item.meta.badge}}
  • ))}
); } ``` Three things dropped out of the sidebar that had to live there before: - **Permission filtering** is now per-item, declared by the owning module. No more `MODULE_NAV_ITEM_ACTIONS` map keyed on stringly-typed labels. - **Dynamic hrefs** are the module's concern. The shell hands over the context; the module owns URL construction. - **Typed i18n keys** catch typos at compile time. `label: "appShell.nav.typo"` fails TypeScript if it isn't a valid `ParseKeys` entry. ## Each generic in isolation You don't have to adopt all four. Pick the ones that pay for themselves. ### `TLabel extends string` — typed i18n labels Narrow labels to an i18n key union so typos fail at compile time. ```ts type NavKey = "appShell.nav.home" | "appShell.nav.billing"; type AppNavItem = NavigationItem; defineModule, AppNavItem>({ navigation: [ { label: "appShell.nav.home", to: "/" }, // ✅ { label: "appShell.nav.typo", to: "/" }, // ❌ Type '"appShell.nav.typo"' is not assignable ], }); ``` `i18next`'s `ParseKeys` union (or the equivalent for your i18n library) plugs in directly. ### `TContext` — dynamic hrefs Some URLs can't be known statically — workspace-scoped paths, feature-flagged routes, active-tab-scoped routes. Declare the context your shell hands over at render time: ```ts interface NavContext { workspaceId: string; } type AppNavItem = NavigationItem; defineModule, AppNavItem>({ navigation: [ { label: "Requests", to: ({ workspaceId }) => `/portal/${workspaceId}/requests` }, { label: "Settings", to: "/settings" }, // still fine — static string ], }); ``` Resolve to a concrete href with `resolveNavHref(item, context)`: ```ts import { resolveNavHref } from "@modular-react/core"; const href = resolveNavHref(item, { workspaceId: "ws-42" }); // → "/portal/ws-42/requests" or "/settings" ``` - Static strings pass through unchanged — context is ignored. - Function `to` without a context throws a helpful error (`"