Multi-Portal Architecture — Role-Based Frontend Routing
Audiences: developer, internal, investor
Eleven role-scoped portals share one Next.js 16 shell, one i18n routing layer, and one role-aware layout factory — each role only sees the routes, navigation, and data its backend RBAC permits.
Business Purpose
The Dudoxx HMS frontend serves eleven distinct user roles under one Next.js application: patients, doctors, nurses, receptionists, accountants, clinic admins, org admins, partners, staff, admins, and super-admins. Each role needs a different home, different navigation, different KPI surface, and different write permissions — but they must all share a single deployed bundle, one auth session, one i18n catalog, and one design system.
Building eleven separate SPAs would balloon DevOps and split the design vocabulary; building one shell with role-gated content keeps every clinical buyer (clinic admin) and every investor demo on the same code path. The cost of an additional role is one config object, not a new application.
Audiences
- Investor: shows how a single product surface serves every constituent in a clinic (provider, support staff, finance, leadership) — a structural moat against single-portal point solutions.
- Clinical buyer (doctor/nurse/receptionist): explains why the doctor's appointment list differs from the receptionist's appointment list even though both are "appointments". Role scoping happens at three layers (proxy gate, layout-level RBAC, backend authorization).
- Developer/partner: covers the one factory function (
createRoleLayout) and one registry (ROLE_CONFIGS) that govern every portal — and the deliberate "bare parent layout" pattern that letsdoctor/(with-nav)/coexist withdoctor/help/. - Internal (ops/support): locates which file owns navigation, brand label, role badge, and backend-status gating for any given role.
Architecture
Three gates per request
Browser → proxy.ts (locale + session-cookie gate)
→ [locale]/portal/<role>/layout (RBAC requireRoleAccess)
→ backend ddx-api (final authoritative RBAC)
The Next.js proxy at ddx-web/src/proxy.ts:248-319 performs a coarse gate: it strips the
locale, classifies the route as protected/auth/public, and redirects to /<locale>/auth/login
when no session cookie is present. It deliberately does not know roles — that decision
lives in the layout factory and the backend.
One layout factory, eleven configs
Each role layout is one to two dozen lines:
// ddx-web/src/app/[locale]/portal/nurse/layout.tsx export default createRoleLayout(NURSE_CONFIG, NurseNav);
The factory at ddx-web/src/components/portal/createRoleLayout.tsx:49-131 does the heavy
lifting:
await params(Next.js 16 async params) → resolve locale.requireRoleAccess(config.authRequirement, locale)→ server-side RBAC gate; redirects to login or/unauthorizedand returns a typedSessionUser.- Fetch the organization (skip for super-admin via
fetchOrg: false). - Resolve brand name, organization name, and tagline from the configured i18n namespace.
- Compose
<DDXHeader>+<PortalShell>+<AppStatusBar>+ the role'sNavigationComponent+ optional<BackendStatusProvider>wrapper. - Mount
<SSEBootstrap userId={user.id}>for SSE channel subscriptions.
Role registry
The eleven configs live in ddx-web/src/lib/portal/role-configs.ts:29-198. Each entry
captures: roleKey (URL segment), authRequirement (RBAC constant), roleBadge (icon +
variant + label key), brandNameKey, optional taglineFormatter, optional extraElements,
and optional needsBackendStatus with scope: 'shell' | 'children'. The full registry is
exported as ROLE_CONFIGS (ddx-web/src/lib/portal/role-configs.ts:187-199).
The doctor "bare parent" pattern
Doctor is the only portal that hosts chrome-free sibling routes (help/, cms/help/)
alongside the full sidebar shell. Next.js nested layouts compose top-down — a child layout
cannot strip chrome added by a parent. The codebase solves this by making
ddx-web/src/app/[locale]/portal/doctor/layout.tsx:1-26 a transparent passthrough
(return <>{children}</>) and pushing the full shell into a route group:
ddx-web/src/app/[locale]/portal/doctor/(with-nav)/layout.tsx:1-29. New doctor pages
must live under (with-nav)/ to inherit DDXHeader + DoctorNav + AppStatusBar.
Tech Stack & Choices
| Layer | Choice | Why |
|---|---|---|
| App router | Next.js ^16.1.6 App Router | Server Components by default → tokens never reach the browser; nested layouts compose RBAC + chrome. |
| Auth | Auth.js v5 (next-auth 5.0.0-beta.30) | JWT strategy; session-cookie validation in proxy.ts; full RBAC in backend. |
| RBAC gate | requireRoleAccess(authRequirement, locale) | One Server Component check per layout; throws redirect when role mismatches. |
| Shell composition | createRoleLayout factory + RolePortalConfig registry | Eleven layouts in <30 lines each; one place to evolve chrome. |
| i18n | next-intl v4 with localePrefix: 'always' | Locale is part of the URL (/en/portal/doctor); no client locale switching ambiguity. |
| Navigation | PortalNavigation roleKey="<role>" (config-driven) | Sectioned or flat nav configs in lib/portal/nav-configs; replaces 11 per-role nav components. |
Rejected: separate apps per role, role detection at request time without URL prefix, client-side role enforcement. All three either fragment the design system or shift authority to a place the browser can tamper with.
Data Flow
- User visits
https://acm.dudoxx.com/en/portal/doctor/dashboard. - proxy.ts matches the host trigram
acm(ddx-web/src/proxy.ts:51-57,:72-113), resolves the slug via/organizations/by-trigram/acm, setsX-Clinic-ID: <slug>header. The trigram regex is strict (ddx-web/src/proxy.ts:35) — apex/www/non-3-letter hosts never inject a header. - Proxy checks session cookie via
hasSessionCookie()(ddx-web/src/proxy.ts:221-229); if missing on a protected route, redirects to/<locale>/auth/login?callbackUrl=.... - Next.js routes the request to
[locale]/portal/doctor/(with-nav)/dashboard/page.tsx. The(with-nav)layout fromcreateRoleLayout(DOCTOR_CONFIG, DoctorNav)runs:requireRoleAccess('DOCTOR_ONLY', locale)→ returns the doctor user or redirects.- Fetches the doctor's
organization. - Composes
<DDXHeader>,<PortalShell>,<DoctorNav>(PortalNavigation roleKey="doctor"), and<AppStatusBar>inside a<BackendStatusProvider>(shell scope).
- The page Server Component runs its own server-side data fetch via
backendGet(...)— every API call carries the user's JWT andX-Clinic-ID; the backend re-validates RBAC and tenant scope authoritatively.
Implicated Code
ddx-web/src/app/[locale]/portal/layout.tsx:16-20— intentionally thin parent; provider stack lives in each role layout.ddx-web/src/components/portal/createRoleLayout.tsx:49-131— the single factory: RBAC, org fetch, brand resolution, shell composition.ddx-web/src/lib/portal/role-configs.ts:29-199— elevenRolePortalConfigentries and theROLE_CONFIGSregistry.ddx-web/src/app/[locale]/portal/doctor/layout.tsx:1-26— the deliberate bare passthrough parent.ddx-web/src/app/[locale]/portal/doctor/(with-nav)/layout.tsx:1-29— full doctor shell inside route group; pages must live here to inherit chrome.ddx-web/src/app/[locale]/portal/patient/layout.tsx:14,ddx-web/src/app/[locale]/portal/nurse/layout.tsx:17,ddx-web/src/app/[locale]/portal/receptionist/layout.tsx:19— one-linecreateRoleLayoutinvocations.ddx-web/src/proxy.ts:115-126—PROTECTED_ROUTES(/portal,/dashboard, etc.) and the locale-stripping helper.ddx-web/src/proxy.ts:248-319— proxy entry: trigram resolution → session gate → intl middleware.ddx-web/src/lib/auth/protection.ts:38-46—requireAuth(locale)server helper.ddx-web/src/lib/api/rbac-config.ts:29-46— backend-facing RBAC registry (mirrors the eleven roles).ddx-web/src/components/portal/navigation/PortalNavigation.tsx:43-67— shared navigation component; takes aroleKeyand resolves the nav config.
Operational Notes
- The
(with-nav)/pitfall — adding a new doctor page directly underportal/doctor/(not under(with-nav)/) yields a chrome-free page with no sidebar. This is a regular source of regressions; the pattern is documented atddx-web/src/app/[locale]/portal/doctor/layout.tsx:1-16. - Never
useTranslationsin a layout —createRoleLayoutis a Server Component and usesgetTranslationsfromnext-intl/server(createRoleLayout.tsx:9). Mixing hooks in here crashes at render. X-Clinic-IDis advisory —proxy.ts:277-285only injects when present trigram resolves; the backend treats the header as a hint and re-derives tenant scope from the JWT. Never readrequest.headers.get('host')directly in feature code without going throughextractTrigramFromHost()(host header is attacker-controlled — see warning atddx-web/src/proxy.ts:31-34).- RBAC fall-through —
defaultDeny: trueinlib/api/rbac-config.ts:44means a new route with no permission entry returns 403 for every role exceptSUPER_ADMIN. Update the matching*_PERMISSIONSmodule underlib/api/rbac-permissions/when shipping a new endpoint. - No
middleware.ts— the file isproxy.tsper project convention (ddx-web/CLAUDE.md:48-49). Creatingmiddleware.tswill silently double-mount.
Related Topics
- Doctor Portal — clinical workflow surface inside
(with-nav)/. - Patient Portal — self-service health records.
- Nurse Portal — vitals, waiting room, intake.
- Receptionist Portal — appointments and check-in.
- i18n — Multi-Lingual Interface — locale routing for every portal.
- TUCAN UI — agent surface embedded in doctor/nurse portals.
- AI Assistant ↔ Visit Wiring — how the doctor portal binds visits to TUCAN.