Elarion

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-contributions

Vite: 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):

vite.config.ts
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 when clause ({ 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-level when is ANDed into every contribution), then sort deterministically by order, 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/ContextOfYour extension points (sidebarItems, …) and payload types
The when evaluator and its strict AND semanticsThe 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, injectContributionsSignal (/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:

platform/contributions.ts
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):

platform/points.ts
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:

modules/invoicing/module.tsx
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 as AnyRoute[] 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:

platform/router.tsx — owns the root route, imported by every module
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>()
platform/modules.ts — what a module's public entry default-exports
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:

modules/invoicing/index.ts
const invoicingModule = { manifest: invoicingManifest, routes: [invoicingRoute] } satisfies AppModule
export default invoicingModule
app.tsx — the composition root
import { 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):

main.tsx — one snapshot gates contributions and routes alike
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:

platform/session.ts — no snapshot yet
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" },
})
platform/contributions.ts — module-only vocabulary
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:

platform/AppShell.tsx
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:

platform/AppSidebar.tsx
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.

main.ts
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:

platform/app-sidebar.component.ts
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:

platform/extension-slot.directive.ts
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:

modules/invoicing/module.tsx
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:

modules/stacks/points.ts — a detail page's tab strip
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")
modules/stacks/StackDetailPage.tsx — the tab host
<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:

platform/router.tsx
import { createRouteGuards } from "@swimmesberger/elarion-contributions/tanstack-router"

export const { redirectUnless } = createRouteGuards<AppVocabulary>()
modules/invoicing/module.tsx
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:

src/routes/__root.tsx — satisfies Start's generator; not used at runtime
import { createRootRoute } from "@tanstack/react-router"
export const Route = createRootRoute()
src/router.tsx — the real, module-composed tree
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 when clauses 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).

On this page