Skip to content

Stack & app layout

The web frontend is a two-app React SPA monorepo. The stack decision lives in nexis-omni-web/docs/adr/0001-frontend-stack.md, and the rule for which of the two apps a given screen belongs in lives in nexis-omni-web/docs/reference/apps.md. This page orients you to both; those repo-local docs are the ground truth.

ADR-0001 adopts a React SPA monorepo and keeps-and-extends the existing scaffold rather than re-scaffolding. There is deliberately no SSR: every surface is internal and auth-gated, so there is no SEO or anonymous-first-paint requirement to justify a server runtime.

Concern Choice
Framework / build React 19 + Vite SPA, React Compiler on
Routing TanStack Router (file-based)
Server state TanStack Query v5 (polling for realtime; push deferred until the backend hub exists)
API client @hey-api/openapi-ts (fetch client + TanStack Query v5 plugin + Zod 4 plugin), generated from the .NET OpenAPI, behind a packages/api-client facade
UI primitives shadcn/ui (own-source) on Base UI (@base-ui/react, not Radix) + Tailwind v4
Forms / tables / dates TanStack Form + Zod, TanStack Table, react-day-picker + date-fns
i18n Paraglide JS - Sinhala / Tamil / English; money renders as Rs. (en-LK)
Lint / format Biome (replacing the template’s ESLint + Prettier)
Monorepo pnpm + Turborepo
Component docs Storybook in packages/ui

Monorepo layout: apps hq-dashboard and platform-admin; packages ui, api-client, auth, tokens, config (and i18n).

Codegen removes hand-written DTO drift across a large surface, but a few things are deliberately hand-built and live behind stable seams so the generated client stays swappable:

  • Two-plane client. Two client instances: an admin client (admin bearer, /admin/*, never sends Tenant-ID) and a tenant client (tenant bearer + a Tenant-ID GUID header). Switching tenant is a full re-login, because the tenant token is cryptographically bound to one tenant_id.
  • Single-flight refresh-on-401. A response interceptor with a module-level guard so concurrent 401s trigger exactly one refresh.
  • Money is a decimal, never a JS number. No OSS generator parses format: decimal at runtime, so one shared Zod schema in packages/api-client decodes the quoted decimal string with Big.js and encodes back with .toString(). A lint rule forbids Number() on money.
  • A codegen guard. The generated client output is committed, and CI regenerates-and-diffs it against a snapshot of the OpenAPI doc, so a drifting contract fails the build instead of silently changing types.

hey-api is adopted defensively (pinned exact version, OSS CLI only, committed output, the CI diff, and the packages/api-client facade) so a fallback to Orval would stay localized.

The monorepo ships two React SPAs that share the same stack and shell, so they look almost identical in code - but they are not interchangeable. They serve different users and talk to two different backend auth planes. Putting a feature in the wrong app is a real, recurring mistake.

hq-dashboard platform-admin
One-liner One tenant business’s online HQ / back-office NexisOmni’s own platform-operator console
Who signs in Tenant business users - owners, managers, back-office staff (AppUser) NexisOmni platform staff (PlatformUser)
Backend plane Tenant / business plane Central / admin plane
Route surface /identity/* + the POS/CRM surface (/branches, /products, /orders, /inventory, /reports, …) /admin/* only
Tenant-ID header Required on every request Never sent
api-client factory createTenantApi() createAdminApi()
i18n Yes - EN / SI / TA No - English-only internal tool

hq-dashboard is the online back-office for a single tenant business: catalogue, multi-branch inventory, sales review, cash sessions, credit, returns, purchasing, reporting, and loyalty. Within the tenant plane, cost / money / valuation operations are Manager-gated, and whole modules can be switched off per tenant (read GET /capabilities and suppress the affordance when a module is off).

platform-admin is the console for NexisOmni’s own staff to administer the SaaS across all tenants: provisioning tenants (POST /admin/tenants), granting a tenant’s built-in roles, toggling per-tenant modules, and gated admin onboarding. It is cross-tenant and never inside one tenant, and it must never send a Tenant-ID.

The backend enforces the split cryptographically, not by convention: two JWT bearer schemes with distinct audiences. An admin token on a tenant route is a 403, and a tenant token on an admin route is a 403. A tenant token is additionally bound to one tenant_id. So a feature belongs to whichever plane its endpoints live on, not to whichever app is handy.

If the screen / feature is about… Build it in Plane
Creating / listing / configuring tenants (companies) platform-admin admin
Granting a tenant’s built-in roles; flipping a tenant’s modules platform-admin admin
Anything NexisOmni’s own ops team uses to run the platform platform-admin admin
Products, inventory, orders, cash, returns, purchasing, reports, loyalty for one business hq-dashboard tenant
A screen a store’s manager / back-office uses hq-dashboard tenant
Anything that needs a Tenant-ID header hq-dashboard tenant
Anything that must never send Tenant-ID platform-admin admin
  • nexis-omni-web/docs/adr/0001-frontend-stack.md - the full stack decision, its rationale, trade-offs, and the two-plane client design. Ground truth for the stack.
  • nexis-omni-web/docs/reference/apps.md - the authoritative guide to which app a screen belongs in, including the code pointers (packages/api-client/src/client.ts, packages/auth) that wire each plane.
  • The two-plane split mirrors the backend; for the auth contract itself see the API contract and the backend developer guide at NexisOmni/docs/developer-guide.md (§2 “Hybrid Identity” and §6 “How authentication works”).