Frontend modules
The contribution model — typed extension points, declarative module manifests, and capability-gated resolution — extends "a module only touches its own code" to the web app.
Elarion's backend has a hard review-isolation property: adding a feature to a module touches only that
module's code. The frontend contribution model
(ADR-0032,
shipped as @swimmesberger/elarion-contributions)
extends that rule to the TypeScript app: a new sidebar item is one edit in the owning module's manifest, a
new module is a new folder (its manifest glob-discovered; its routes one typed registration line — the
ProjectReference grain), and a cross-module action is a manifest entry plus one token import — the shell
never changes.
npm install @swimmesberger/elarion-contributionsVite: dedupe React before anything else
The /react bindings call hooks, so they must resolve to your app's single React instance. Vite's
dependency optimizer can pre-bundle the package's subpath exports against a second React copy (they are
discovered late, deep in the module graph), and the failure reads like an app bug, not a config gap:
Invalid hook call. Hooks can only be called inside of the body of a function component
TypeError: Cannot read properties of null (reading 'useContext')
at useContributions (@swimmesberger_elarion-contributions_react.js)If you see this, add both hints to vite.config.ts (and clear node_modules/.vite):
resolve: { dedupe: ["react", "react-dom"] },
optimizeDeps: {
include: [
"@swimmesberger/elarion-contributions",
"@swimmesberger/elarion-contributions/react",
"@swimmesberger/elarion-contributions/tanstack-router",
],
},There is no package-side lever that prevents this — Vite does not consult peer-dependency metadata when pre-bundling — so the sample ships this config and so should you.
The model in one pass
- A frontend module is a folder that ships a manifest — plain data plus lazy component references —
from its public entry. There is no import-time
registry.register(...): manifests are inspectable without executing module code, deterministic, and testable as plain arrays. - An extension point is a typed token (
defineExtensionPoint<TItem, TContext>). Exporting one from a module's public entry is the frontend[ModuleContract]: contributors import the token — an explicit, compile-checked, correctly-directed dependency — without pulling the owner's components into their chunk. - Every contribution carries a
whenclause ({ module?, permission?, flag?, role? }) evaluated against the client-capability snapshot. The vocabulary is the generated literal unions, and clauses are checked strictly against it:when: { permission: "invocies.read" }fails to compile instead of silently hiding the item. Declare only the axes your app has — an omitted axis rejects every use, so a no-auth app binding{ module: ModuleName }gets a compile error on any stray permission/flag/role clause. - The registry resolves once per snapshot: filter by
when(a manifest-levelwhenis ANDed into every contribution), then sort deterministically byorder, then id. Contribution ids double as render keys, so resolution throws if two co-visible contributions to one point share an id — prefix ids with the module name ("invoicing.create-invoice") to stay collision-free. A slot renders the resolved list; server render and client hydration see identical trees.
Contribution visibility is a read-only UX projection, never an enforcement boundary. A hidden sidebar
item is not a secured operation — the handler's own [RequirePermission]/[FeatureGate] gates every call
server-side (see Authorization).
What you import vs. what you own
The package ships the machinery with fixed semantics; the application owns the points and the shell — Elarion deliberately ships no UI kit and no router integration:
| You import (fixed semantics) | You own (copy from the sample) |
|---|---|
defineExtensionPoint, defineModule, contribute, ItemOf/ContextOf | Your extension points (sidebarItems, …) and payload types |
The when evaluator and its strict AND semantics | The kit instantiation binding your (generated or hand-authored) vocabulary |
createContributionRegistry (filter + deterministic order + id validation) | The app shell that renders each slot |
createStaticCapabilities (the no-snapshot CapabilityReader) | The real snapshot wiring once elarion.session exists |
ContributionProvider, useContributions, <ExtensionSlot context=…> (/react) | Route composition and module discovery (the glob) |
provideContributions, injectContributions→Signal (/angular) | Slot rendering — an @for block, or a self-owned *extensionSlot directive |
redirectUnless route guard (/tanstack-router) | Everything else routing — TanStack's own API |
Keeping the machinery imported is what preserves review isolation: the semantics live in the framework and
are not re-reviewed (or silently forked) in every application. Keeping the points app-owned is the same
rule as the backend: Elarion ships the [ModuleContract] mechanism, never your contracts.
Wiring it up
Bind the kernel once to the generated capability vocabulary, in the app's platform folder:
import { createContributionKit, type ModuleManifest } from "@swimmesberger/elarion-contributions"
import type { FlagName, ModuleName, PermissionName, RoleName } from "@/generated/session-client"
export interface AppVocabulary {
module: ModuleName
permission: PermissionName
flag: FlagName
role: RoleName
}
export type AppManifest = ModuleManifest<AppVocabulary>
export const { defineModule, defineExtensionPoint, contribute } = createContributionKit<AppVocabulary>()Declare only the axes your application has: when clauses are checked strictly, and an omitted axis
rejects every value. A backend without permissions/flags/roles binds { module: ModuleName } and any
stray when: { permission: … } is a compile error — see
No auth, no session snapshot yet for the full recipe.
Declare points where the slot's owner lives — the shell for a sidebar, a module for its own surface. The
payload shape is deliberately yours: whatever your shell needs to render an entry (keep to a plain
string so the point stays router-agnostic data — the shell is the one place that hands it to a router
link):
import type { LucideIcon } from "lucide-react"
import { defineExtensionPoint } from "@/platform/contributions"
export interface SidebarItem {
readonly label: string
readonly icon: LucideIcon
readonly to: string
}
export const sidebarItems = defineExtensionPoint<SidebarItem>("platform.sidebar")A module declares everything it adds in its manifest. A backend-paired module gates itself once at the manifest level, so disabling the backend module removes the whole frontend module:
export const invoicingManifest = defineModule({
name: Modules.Invoicing,
when: { module: Modules.Invoicing },
contributes: [
contribute(sidebarItems, [{
id: "invoices", label: "Invoices", icon: ReceiptText, to: "/invoices", order: 20,
when: { permission: Permissions.invoices.read },
}]),
],
})The composition root: discover manifests, register routes
Two different jobs meet at the composition root, and the recommended split treats them differently:
- Manifests are discovered with a Vite glob. The high-frequency change — a new sidebar item, tab, section, or a route-less module — appears with zero central edits.
- Routes are registered statically, one typed line per route-owning module — the same grain as a
backend host adding a
ProjectReference. This keeps TanStack Router's full inference; a glob-composed tree types asAnyRoute[]and silently degrades far more than navigation (see the callout below).
The file layout matters because modules call getParentRoute: () => rootRoute while the composition root
imports the modules — so rootRoute must live in its own file or the two form an import cycle:
import { createRootRouteWithContext } from "@tanstack/react-router"
import { createRouteGuards } from "@swimmesberger/elarion-contributions/tanstack-router"
export interface RouterContext {
readonly caps: SessionCapabilities
}
export const rootRoute = createRootRouteWithContext<RouterContext>()({ component: AppShell })
export const { redirectUnless } = createRouteGuards<AppVocabulary>()import type { AnyRoute } from "@tanstack/react-router"
import type { AppManifest } from "@/platform/contributions"
export interface AppModule {
readonly manifest: AppManifest
/** The route subtrees this module owns; empty for a UI-only module. */
readonly routes: readonly AnyRoute[]
}A module bundles its manifest with the routes it owns — satisfies (not a type annotation, which would
widen to AnyRoute[]) keeps the routes' concrete types for the static registration:
const invoicingModule = { manifest: invoicingManifest, routes: [invoicingRoute] } satisfies AppModule
export default invoicingModuleimport { createRouter } from "@tanstack/react-router"
import clients from "@/modules/clients"
import invoicing from "@/modules/invoicing"
import { rootRoute } from "@/platform/router"
import type { AppModule } from "@/platform/modules"
// Manifest discovery: compile-time (Vite expands the glob into static imports), deterministic, zero
// central edits for new contributions.
const discovered = import.meta.glob<AppModule>("./modules/*/index.ts", { eager: true, import: "default" })
export const appModules: ReadonlyArray<AppModule> = Object.values(discovered)
// Route registration: one typed line per route-owning module.
const routeTree = rootRoute.addChildren([indexRoute, ...clients.routes, ...invoicing.routes])
export const router = createRouter({ routeTree, context: { caps: undefined! } })
declare module "@tanstack/react-router" {
interface Register { router: typeof router }
}The entry point then wires both consumers of the capability snapshot in one place — the registry (for
slots) and the router context (for redirectUnless guards):
import { createContributionRegistry } from "@swimmesberger/elarion-contributions"
import { ContributionProvider } from "@swimmesberger/elarion-contributions/react"
import { loadCapabilities } from "@/platform/session"
import { appModules, router } from "./app"
const caps = await loadCapabilities() // the /session snapshot (see Client capabilities)
const registry = createContributionRegistry(appModules.map((m) => m.manifest), caps)
createRoot(document.getElementById("root")!).render(
<ContributionProvider registry={registry}>
<RouterProvider router={router} context={{ caps }} />
</ContributionProvider>
)The glob-routes alternative, and its full cost
You can compose routes from the glob too — rootRoute.addChildren(appModules.flatMap((m) => m.routes)) — and get zero-edit route discovery. Know the full price: the tree types as AnyRoute[], so
Link to loses its literal union, and useLoaderData/useParams collapse to untyped fallbacks —
every consumer of route data, not just navigation, and under noImplicitAny a loader-using app surfaces
dozens of errors. The workable escape is registering the router as AnyRouter (so to/params fall back
to string instead of erroring), which trades away typed routing app-wide. Teams that don't use route
loaders sometimes accept this deliberately; make it a decision, not a surprise.
No auth, no session snapshot yet
A common adoption shape — self-hosted apps behind an authenticating reverse proxy, or a backend that has
not shipped the elarion.session operation yet — has no generated session-client.ts. The model
supports this first-class; nothing about it requires the generator.
Hand-author the vocabulary with only the axes you have (module names mirroring the backend's
[AppModule] markers), and use the shipped static reader instead of a hand-rolled one:
import { createStaticCapabilities } from "@swimmesberger/elarion-contributions"
/** Mirrors the backend [AppModule] names. Replace with the generated ModuleName when the snapshot lands. */
export const Modules = { Core: "core", AiAgent: "ai-agent" } as const
export type ModuleName = (typeof Modules)[keyof typeof Modules]
// Modules/permissions/roles default to "all"; flags default to none (fail-closed, like the evaluator).
// Env-driven module toggles are one map away:
export const caps = createStaticCapabilities({
modules: { core: true, "ai-agent": import.meta.env.VITE_MODULE_AI_AGENT_ENABLED !== "false" },
})export interface AppVocabulary {
module: ModuleName
// No permission/flag/role axes: any `when: { permission: … }` anywhere is now a compile error,
// so the vocabulary can't silently rot ahead of the backend.
}
export const { defineModule, defineExtensionPoint, contribute } = createContributionKit<AppVocabulary>()The migration seam is the point of this shape: createStaticCapabilities returns the same structural
CapabilityReader the generated SessionCapabilities satisfies. When the backend ships the session
operation, regenerate the client, re-export Modules/ModuleName from the generated file, widen
AppVocabulary with the new axes, and swap caps for createSessionCapabilities(snapshot) — no module
or manifest changes.
The approach: a modular sidebar
The division of labor for any shell surface: the framework ships the mechanics (the registry, when
filtering, the slot bindings), the app owns the point's payload shape and one file of look, and
modules supply data. The shell renders whatever resolved — it never knows the contributors, so it
never changes when a module is added, and a disabled module's entries are simply absent. The integration
is always the same single move, whatever the UI kit: wherever your shell would map a hard-coded array
of nav items, map useContributions(point) instead.
With plain elements:
import { Link, Outlet } from "@tanstack/react-router"
import { useContributions } from "@swimmesberger/elarion-contributions/react"
import { sidebarItems } from "@/platform/points"
export function AppShell() {
const items = useContributions(sidebarItems) // filtered by `when`, deterministically ordered
return (
<div className="flex min-h-screen">
<aside className="w-56 border-r px-3 py-6">
<nav className="flex flex-col gap-1">
{items.map((item) => (
<Link key={item.id} to={item.to} activeProps={{ className: "bg-accent font-medium" }}>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
</aside>
<main className="flex-1 px-8 py-10">
<Outlet />
</main>
</div>
)
}With shadcn/ui's Sidebar
shadcn/ui's Sidebar renders its menu from an items array
in every one of its examples — that array is exactly where the contributions go. Add the component
(npx shadcn@latest add sidebar), keep its structure, and swap the hard-coded const items = [...] for
the resolved contributions:
import { Link } from "@tanstack/react-router"
import { useContributions } from "@swimmesberger/elarion-contributions/react"
import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel,
SidebarMenu, SidebarMenuButton, SidebarMenuItem,
} from "@/components/ui/sidebar"
import { sidebarItems } from "@/platform/points"
export function AppSidebar() {
const items = useContributions(sidebarItems) // was: a hard-coded items constant
return (
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton asChild>
<Link to={item.to}>
<item.icon />
<span>{item.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
)
}The shell wraps it exactly as shadcn documents — <SidebarProvider><AppSidebar /><main><SidebarTrigger /> <Outlet /></main></SidebarProvider> as the root route's component — and collapsing, the mobile sheet,
and theming all come from shadcn untouched. Sidebar sections are the same idea one level up: either
group by a field your payload carries (group: "main" | "admin" on the item type, one <SidebarGroup>
per group), or define one extension point per section when the sections themselves are the contract
between shell and modules.
A module's sidebar entry is then one manifest item (the Invoicing example above) — label, icon, target,
order, when — and nothing else: no shell edit, no registration call. The same pattern serves any
shell surface (top nav, settings sections, dashboards, command palettes): define a point, render
useContributions/<ExtensionSlot> where it lives. The shell's look is deliberately yours — copy the
sample's AppShell or shadcn's blocks as a starting point rather than importing one from Elarion (the
no-UI-kit non-goal).
With Angular
The same kernel drives an Angular app through the /angular sub-export — idiomatic for Angular 20–22:
signal-first, standalone, no NgModule. provideContributions is an environment provider (shaped like
provideRouter); injectContributions returns a Signal your template tracks, so refreshing the
snapshot re-resolves every slot by setting one signal. The bindings are deliberately decorator- and
template-free, so they ship in the one package with no separate Angular build.
import { createContributionRegistry } from "@swimmesberger/elarion-contributions"
import { provideContributions } from "@swimmesberger/elarion-contributions/angular"
const registry = createContributionRegistry(appModules.map((m) => m.manifest), caps)
bootstrapApplication(AppComponent, { providers: [provideContributions(registry)] })
// snapshot can change at runtime? provideContributions(signal(registry)) and .set(...) on login.The sidebar is the same move — wherever you'd @for over a hard-coded array, read the point instead:
import { Component } from "@angular/core"
import { RouterLink, RouterLinkActive } from "@angular/router"
import { injectContributions } from "@swimmesberger/elarion-contributions/angular"
import { sidebarItems } from "@/platform/points"
@Component({
selector: "app-sidebar",
standalone: true,
imports: [RouterLink, RouterLinkActive],
template: `
<nav class="flex flex-col gap-1">
@for (item of items(); track item.id) {
<a [routerLink]="item.to" routerLinkActive="bg-accent font-medium">{{ item.label }}</a>
}
</nav>
`,
})
export class AppSidebar {
readonly items = injectContributions(sidebarItems) // filtered by `when`, deterministically ordered
}Prefer slot sugar over an inline @for? The package ships no directive — that would need the Angular
compiler and a separate build. A *extensionSlot structural directive is instead a few lines you own on
top of injectContributions, and because it's yours the rendering stays in your app while the kernel
stays framework-agnostic:
import {
Directive, effect, inject, Injector, input, runInInjectionContext, TemplateRef, ViewContainerRef,
} from "@angular/core"
import { injectContributions } from "@swimmesberger/elarion-contributions/angular"
import type { Contribution, ExtensionPoint } from "@swimmesberger/elarion-contributions"
@Directive({ selector: "[extensionSlot]", standalone: true })
export class ExtensionSlotDirective<TItem> {
// *extensionSlot="point" — the point token to render.
readonly point = input.required<ExtensionPoint<TItem, unknown>>({ alias: "extensionSlot" })
private readonly tpl = inject<TemplateRef<{ $implicit: Contribution<TItem> }>>(TemplateRef)
private readonly vcr = inject(ViewContainerRef)
private readonly injector = inject(Injector)
constructor() {
// Re-render when the point changes or the snapshot refreshes; each item is the template's $implicit.
// injectContributions() calls inject(), so it must run in an injection context — hence runInInjectionContext.
effect(() => {
const items = runInInjectionContext(this.injector, () => injectContributions(this.point())())
this.vcr.clear()
for (const item of items) this.vcr.createEmbeddedView(this.tpl, { $implicit: item })
})
}
}The slot site then reads like TanStack's <ExtensionSlot>, with the contribution as the template's
implicit value:
<a *extensionSlot="sidebarItems; let item" [routerLink]="item.to">{{ item.label }}</a>Cross-module contribution
A module publishes a point from its public entry; another module imports the token and contributes. Payloads carry lazy component references, so the contributed UI stays in the contributor's chunk and downloads on first use:
import { clientRowActions } from "@/modules/clients" // the Clients module's published token
contribute(clientRowActions, [{
id: "invoicing.create-invoice", // module-prefixed: ids must be unique within the point
label: "New invoice for this client",
icon: FilePlus2,
component: lazy(() => import("./components/CreateInvoiceForClient")
.then((m) => ({ default: m.CreateInvoiceForClient }))),
when: { permission: Permissions.invoices.write },
}])The slot owner renders whatever resolved — if Invoicing is disabled, the action simply never appears; if
Clients is disabled, its slot never renders and the contribution is inert. Neither module sees the other's
internals: the boundary is the public entry (modules/{name}/index.ts by convention; workspace packages
with exports maps enforce the same rule at scale — the ELMOD002 analog).
Slot context: delivering what the point promises (TContext)
A point's second type parameter declares what the slot site supplies to every contribution — void for
context-free slots like a sidebar. It is not advisory: reference it from the payload with ContextOf, and
supply it through the slot's context prop, and the payload's signature, the point's declaration, and the
slot site can never drift apart:
import type { ContextOf } from "@swimmesberger/elarion-contributions"
export interface StackTabContext {
readonly stack: Stack
}
export interface StackTab {
readonly value: string
readonly label: string
/** ContextOf pins this signature to the point's declaration — the two cannot diverge. */
readonly component: (context: ContextOf<typeof stackDetailTabs>) => ReactNode
}
export const stackDetailTabs = defineExtensionPoint<StackTab, StackTabContext>("stacks.detailTabs")<Tabs value={activeTab} onValueChange={setTab}>
<ExtensionSlot
point={stackDetailTabs}
context={{ stack }} // type-checked against StackTabContext
render={(tab, context) => (
<TabsContent key={tab.id} value={tab.value}>{tab.component(context)}</TabsContent>
)}
/>
</Tabs>Everything a contribution needs from its host belongs in the context — callbacks included. When the
host later has more to offer (say a "view log" callback beside stack), widen TContext at the point:
every slot site and every ContextOf-typed payload follows by compile error, instead of the extra value
being smuggled through a separate React context beside the model.
Slots that render only inert parts (buttons, menu entries) and mount the payload's component later — at
invocation — keep the context-free render={(item) => …} form and hand the context over at the call
site; the Billing sample's client row actions do exactly that.
Routes
Routes are module-owned router subtrees, composed with the router's own API — Elarion adds no routing
machinery (an ADR-0032 non-goal). With TanStack Router, each module exports the routes it owns (routes: readonly AnyRoute[] — real modules usually own several) and the composition root registers them
statically, as shown above. Each module's
pages are their own chunk (lazyRouteComponent); a disabled module's chunk never downloads.
The one piece the framework does own is the guard semantics: the /tanstack-router sub-export ships
redirectUnless, a beforeLoad guard that evaluates the same when clause as a contribution — same
AND, same fail-closed behavior — so a route and its sidebar item can never gate differently. Bind it to
your vocabulary once, next to the root route:
import { createRouteGuards } from "@swimmesberger/elarion-contributions/tanstack-router"
export const { redirectUnless } = createRouteGuards<AppVocabulary>()export const invoicingRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/invoices",
// The route-level mirror of the sidebar item's `when`: a deep link into a hidden module bounces home.
beforeLoad: redirectUnless({ module: Modules.Invoicing, permission: Permissions.invoices.read }, "/"),
component: lazyRouteComponent(() => import("./InvoicesPage"), "InvoicesPage"),
})TanStack Start (SSR)
The sample is a plain Vite SPA. On TanStack Start, two of its build steps assume file-based routing and fail against a purely code-composed tree:
- the router-generator errors without a
src/routes/directory (ENOENT: scandir '…/src/routes'), and - the SSR-manifest plugin builds from the generated route tree
(
start-manifest-plugin: Cannot convert undefined or null to object).
The production-proven shim: keep a minimal file root to satisfy the generator and the manifest,
compose the real module tree in code inside the router factory, and gitignore the generated
routeTree.gen.ts:
import { createRootRoute } from "@tanstack/react-router"
export const Route = createRootRoute()export function getRouter() {
const routeTree = rootRoute.addChildren([indexRoute, ...clients.routes, ...invoicing.routes])
return createRouter({ routeTree, context: { caps, queryClient, registry } })
}On Start, the real root route (platform/router.tsx) also owns the SSR document shell — <html>,
HeadContent, Scripts, and the <ContributionProvider> around the outlet — since no file route renders
it. The shim is sound while the app ships as a single bundle: the root-only manifest carries no route
chunks, so it has no practical effect. An app that wants Start's per-route code-splitting should instead
describe the module-owned route files to the generator with TanStack's virtual file routes
(virtualRouteConfig), so the generated tree and the SSR manifest stay real.
Registry resolution is deliberately SSR-pure: the same (manifests, snapshot) input yields the same
resolved lists, so a server render and the client hydration paint identical trees — build the registry
once per request from that request's snapshot and hand it to both the provider and the router context.
redirectUnless guards run in beforeLoad on the server exactly as in the SPA.
Reference usage
The Billing sample's web app is the living reference: module folders, the shell-owned sidebar point, the cross-module row-action point, snapshot-gated routes, and the composition root — start a new app by copying its app-owned half.
See also
- Client capabilities — the snapshot and typed vocabulary
whenclauses evaluate against. - Cross-module communication — the backend boundary model this mirrors.
- ADR-0032 — decisions and non-goals (no micro-frontends, no C#-declared UI, no UI kit, no bespoke router).
Client capabilities
One bootstrap snapshot — modules, feature flags/variants, and the user's grants — projected to the frontend over OpenFeature so the UI can hide or adapt itself.
Idempotency
Declarative, transport-neutral, exactly-once command replay — [Idempotent] over a single-transaction, unique-constrained key store, with the key committed atomically with the operation.