Kulcare Docs

Routing Architecture Guide

This document explains how routing works in the Kulcare micro-frontend architecture, including authentication flows, protected routes, and module federation integration.

Table of Contents

  1. Overview
  2. Routing Hierarchy
  3. Authentication Flow
  4. Protected Routes
  5. Layout Components
  6. Module Federation Routing
  7. Navigation Patterns
  8. Route Configuration
  9. Best Practices
  10. Navigation Menu Integration
  11. Testing Routes

Overview

The shell (frontend/apps/shell) owns the top-level router. It signs the user in, decides if a page needs a facility, and loads each micro-frontend at its path. Each micro-frontend then routes inside its own path.

The sign-in and facility rules on this page follow the plan docs/plans/2026-10-06-feat-auth-connect-landing-plan.md. The authentication flow map has a diagram of each flow.

Shell application (host)
├── Guest routes (GuestRoute, no shell chrome)
│   ├── /signin
│   ├── /signup
│   ├── /forgot-password
│   ├── /verify-otp            (the password-reset code)
│   └── /reset-password
├── Routes with no guard (no shell chrome)
│   ├── /identity/verify-otp   (the signup and unverified-login code)
│   └── /select-profile        (the facility picker)
├── Redirects
│   ├── /login         → /signin
│   ├── /select-clinic → /select-profile
│   ├── /onboarding    → /connect
│   ├── /              → /connect
│   └── /* (unknown)   → /connect
└── Protected routes (ProtectedRoute, shell layout)
    ├── Need no facility (requireClinic={false})
    │   ├── /connect/*                  → Connect app
    │   ├── /research/*                 → Research app (when SHOW_RESEARCH_APP is on)
    │   ├── /account/preferences/:tab
    │   ├── /account/facilities
    │   ├── /settings/two-factor-authentication
    │   ├── /help/:tab
    │   └── /style-guide
    └── Need a facility (the default)
        ├── /cms/*                      → CMS app
        ├── /growth/*                   → Growth app
        ├── /reports/*                  → Reports app
        ├── /hsp/*                      → Hospital app
        └── /account, /account/users, /account/subscription, billing, invoices,
            payment methods, billing address, permissions, audit trail

Files:

  • apps/shell/src/App.tsx: the guest routes, GuestRoute, the picker and the OTP page.
  • apps/shell/src/components/AppRoutes.tsx: the protected routes and the redirects.
  • apps/shell/src/components/ProtectedRoute.tsx: ProtectedRoute and AutoSelectFacility.
  • apps/shell/src/components/lazyApps.ts: the lazy imports of the micro-frontends. They sit in their own module, so a test can mount AppRoutes with stubs.
  • apps/shell/src/services/identity/postAuth.ts: finishSignIn, signInTarget and silentSwitchTarget.

Layout Architecture

  1. Shell layout (apps/shell/src/layouts/ShellLayout.tsx): an <Outlet /> and nothing else. It draws no header and no sidebar.
  2. App layouts: each app draws its own chrome with the shared MicroAppLayout from @kulcare/ui-kit. The shell account pages use DashboardSidebar, which also renders MicroAppLayout.
  3. No layout: the sign-in pages, the OTP page and the picker render with no chrome.

Routing Hierarchy

1. Shell Router (Top Level)

// apps/shell/src/App.tsx (shortened)
<BrowserRouter>
  <Routes>
    <Route path="/signin" element={<GuestRoute><IdentitySignInPage /></GuestRoute>} />
    <Route path="/select-profile" element={<IdentitySelectProfilePage />} />
    <Route path="/identity/verify-otp" element={<IdentityVerifyOtpPage />} />
    {/* ... the other guest routes and the legacy redirects */}
    <Route element={<ShellLayout />}>
      <Route path="/*" element={<AppRoutes />} />
    </Route>
  </Routes>
</BrowserRouter>

2. AppRoutes Component

AppRoutes wraps each page in ProtectedRoute. The guard reads the auth store. It does not split the route table by state.

// apps/shell/src/components/AppRoutes.tsx (shortened)
<Routes>
  <Route path="/" element={<Navigate to="/connect" replace />} />
  <Route path="/cms/*" element={<ProtectedRoute><CMSApp /></ProtectedRoute>} />
  <Route
    path="/connect/*"
    element={<ProtectedRoute requireClinic={false}><ConnectApp /></ProtectedRoute>}
  />
  {/* ... */}
  <Route path="/*" element={<Navigate to="/connect" replace />} />
</Routes>

Authentication Flow

The end of every sign-in

Every sign-in ends in one function, finishSignIn (services/identity/postAuth.ts). This applies to the password sign-in, the 2FA sign-in, the signup OTP and the unverified-login OTP. The facility picker does not open at sign-in.

  1. finishSignIn reads the new profile list from the auth store.
  2. If the user has exactly one facility in a practice that is not suspended or cancelled, it switches into that facility. It uses the shared switchFacility, which cancels and refreshes cached data. A failed switch goes to the console and does not stop the sign-in, because Connect needs no tenant.
  3. It goes to signInTarget(next) with replace.

signInTarget(next) returns next when safeReturnPath accepts it. Otherwise it returns /connect. A bare / also gives /connect. The target is /connect and not /connect/dashboard, because only the Connect index checks onboarding. Connect sends an unfinished user to onboarding and a finished user to the dashboard.

The sign-in sets no tenant on the backend session (D3). Only a profile switch sets it.

The return path, next

The page the user asked for travels in the next query parameter. It survives a reload and the OTP step. The backend invite links use it too (/signin?next=/connect/invites).

  • ProtectedRoute sends a signed-out user to /signin?next=<path, search and hash>.
  • SignInPage reads next. On the unverified-login branch it passes next to /identity/verify-otp in the router state.
  • GuestRoute sends a signed-in visitor to signInTarget(next).
  • The facility picker returns to safeReturnPath(next), or to /connect.

safeReturnPath(raw) (packages/auth-identity/src/utils/safeReturnPath.ts) accepts a value only when it is a path on this site. It returns the value, or null:

  • The value must start with one /. It must not start with // or /\.
  • The path part must not contain a colon, so a scheme such as javascript: fails. The query and the hash may contain a colon.
  • The value must not contain a control character, raw or percent-encoded (%0d%0a), or a raw space.
  • The same checks run on the decoded value, so /%2F%2Fevil.example fails. A value that does not decode fails.

Connect's safeInternalPath (components/inbox/safeHref.ts) uses the same helper after a trim.

GuestRoute and a sign-in in progress

The store marks the user signed in before the silent switch ends. GuestRoute would then leave the sign-in page too early. So SignInPage calls beginSignIn() before the request, and finishSignIn clears the flag after it navigates. While the flag is set, GuestRoute does not redirect. abortSignIn() clears the flag after an error or a 2FA step.

/identity/verify-otp is not under GuestRoute. A correct code signs the user in, and the page must finish its own sign-in. The page sends a signed-in visitor to signInTarget(next).

Protected Routes

ProtectedRoute

ProtectedRoute (apps/shell/src/components/ProtectedRoute.tsx) takes these props:

PropDefaultUse
requireClinictrueThe page needs a selected facility (a tenant token).
requiredPermissionsnoneAny of these permissions opens the page.
deniedMessagea subscription messageThe text when the permission check fails.
<ProtectedRoute>
  <CMSApp />
</ProtectedRoute>

<ProtectedRoute requireClinic={false}>
  <ConnectApp />
</ProtectedRoute>

<ProtectedRoute requiredPermissions={AREA_KEYS.marketing} deniedMessage="...">
  <GrowthApp />
</ProtectedRoute>

The guard runs these checks in this order:

  1. Signed out: go to /signin?next=<the page>.
  2. requireClinic and no selected facility:
    • No facility choices: go to /connect.
    • One facility in an open practice: AutoSelectFacility switches into it, then the page renders.
    • Several facilities, or one in a suspended or cancelled practice: go to /select-profile?next=<the page>.
  3. requiredPermissions while the permissions load: render nothing.
  4. requiredPermissions not met: show UnauthorizedMessage.

A facility choice is one facility of one membership row. The patient row has no tenant, so it is never a choice (D5). membershipProfiles and facilityChoices in packages/auth-identity/src/utils/membershipProfiles.ts do this count.

AutoSelectFacility

AutoSelectFacility selects the user's only facility and then lets the page render (D7).

  1. It asks ProtectedRoute to hold the page on a spinner. The new token alone would give the guard a selected profile, so the hold starts before the switch.
  2. It calls the shared switchFacility once, with the reason auto_select.
  3. It reads the practice's feature flags into the store (loadTenantFeatureFlags). Without this step, a page gated on a practice flag shows "not available" until FeatureFlagProvider refetches.
  4. It releases the hold, and the page renders.

A failed switch sends the user to /select-profile?next=<the page>.

The facility picker, /select-profile

The picker opens only on demand: when a user opens a page that needs a facility and the guard cannot choose one. It does not open at sign-in.

  • It shows one card for each facility choice, grouped by role.
  • A pick calls the shared switchFacility with the page's query client, then loadTenantFeatureFlags. Then it goes to safeReturnPath(next) or /connect.
  • On a direct visit with no next and one open facility, it selects that facility after 1.5 seconds. With next, the user came from a page whose own switch failed or was refused, so they choose.
  • A suspended practice shows a "Suspended" pill and "This practice is suspended. Contact Kulcare support." A cancelled practice shows a "Cancelled" pill and a closed message. Neither card offers a switch (D9).
  • A refused switch shows the support message, not the raw backend text.
  • The card body (UnavailablePracticeNotice) has an actions slot. It holds "Open read-only" and, where payment restores the practice, "Go to billing" (plan U10).

Pages that need no facility

These pages call user endpoints only, so they use requireClinic={false} (D2): /connect/*, /research/*, /account/preferences/:tab, /account/facilities, /settings/two-factor-authentication, /help/:tab and /style-guide.

Every other account page keeps the facility check: /account, users, the practitioner page, permissions, subscription, billing, billing address, payment methods, invoices and audit trail. AppRoutes.test.tsx mounts the real route table and checks each page.

Suspended practices on the backend

The frontend never switches silently into a suspended practice. The backend has its own rules (U10 backend, built):

  • A profile switch into a suspended practice succeeds for an active member. The session gets tenant_access: 'read_only'. A switch into a cancelled practice fails.
  • Shared::Concerns::SuspendedTenantGuard runs on every Shared::BaseController request. On a suspended or cancelled tenant, GET and HEAD pass, and any other method gets 403 TENANT_SUSPENDED. The payment path is open to a member with manage_subscription.
  • A read_only session on a practice that is active again gets 409 PROFILE_SWITCH_REQUIRED. The client must switch again.

The frontend does not yet offer the read-only switch or handle these codes. That is the U10 frontend work.

Route Protection Levels

  1. Guest routes: GuestRoute. A signed-in visitor goes to signInTarget(next).
  2. Signed-in routes: ProtectedRoute requireClinic={false}. No facility is needed.
  3. Facility routes: ProtectedRoute. A facility is selected silently or through the picker.
  4. Permission routes: ProtectedRoute requiredPermissions={...}.

A suspended practice

A suspended practice opens read-only (plan 2026-10-06-feat-auth-connect-landing-plan.md, U10). The shell guard and the apps follow these rules:

  • ProtectedRoute switches silently only into the one open facility, and only when no practice of the user is suspended. A user with a suspended practice goes to /select-profile?next=<page>, because the page may be about that practice.
  • The picker card of a suspended practice has "Open read-only" for every member. It has "Go to billing" only when payment restores the practice: the reason is non_payment and the member has manage_subscription (canPayToRestore in @kulcare/auth-identity). A cancelled practice stays closed.
  • useTenantAccess() reads the selected practice row, so it is right after a reload. The shell sidebar and the CMS, Hospital, Reports and Growth layouts show SuspendedPracticeBanner from it.
  • The main write actions are disabled while useTenantAccess().isReadOnly is true. Each one shows ReadOnlyReason from @kulcare/ui-kit ("This practice is read-only while it is suspended.") and puts READ_ONLY_REASON in its title:
    • CMS: add and edit on the patient list, the add patient form and the patient form page, the Add appointment button and booking modal (queue and scheduled), the queue Add button, and encounter Save, Finalize and Copy Rx. The prescription is saved with the encounter.
    • Hospital: the bed board movement actions, which include Admit patient, and Mark ready and Post line on the billing workbench.
    • Growth: Create campaign, the compose wizard submit, Create Ad Campaign, the theme buttons and Launch Ad Campaign.
  • A write answers 403 TENANT_SUSPENDED. The shell shows one fixed toast, and each app layout wraps its toast with guardErrorToastsAfterTenantSuspended, so the page's own error toast does not follow.
  • After a payment restores the practice, the session is still read-only and answers 409 PROFILE_SWITCH_REQUIRED. The api-client raises kulcare-profile-switch-required, and the shell switches again into the same facility (useProfileSwitchRequired). The checkout does the same at once when verify says tenant_reactivated.

Layout Components

Shell Layout

ShellLayout is a pass-through. It renders the matched route and adds no chrome:

// apps/shell/src/layouts/ShellLayout.tsx
export function ShellLayout() {
  return <Outlet />
}

Each page draws its own sidebar. The shell account pages (/account/*, /settings/*, /help/*) wrap their content in DashboardSidebar (apps/shell/src/components/DashboardSidebar.tsx).

Micro-Frontend Layouts

Every app layout renders MicroAppLayout (packages/ui-kit/src/layouts/MicroAppLayout.tsx). The layout draws the sidebar, the app switcher rail, the facility switcher, the user menu and an optional top bar. The app gives it the menu, the user, the facilities and the callbacks.

LayoutFilecurrentApp for the rail
Shell account pagesapps/shell/src/components/DashboardSidebar.tsxnone (the shell is not an app in the rail)
CMSapps/cms/src/layouts/CMSLayout.tsxcms
Growthapps/growth/src/layouts/GrowthLayout.tsxgrowth
Reportsapps/reports/src/layouts/ReportsLayout.tsxreports
Connectapps/connect/src/layouts/ConnectLayout.tsxconnect (rail off, see below)
Hospitalapps/hospital/src/layouts/HospitalLayout.tsxhospital
Researchapps/research/src/layouts/ResearchLayout.tsxresearch

The main props:

  • menuItems, selectedKey, onMenuClick: the app's own navigation.
  • apps, currentApp, showAppSwitcher: the app switcher rail (see Navigation Menu Integration).
  • selectedFacility, availableFacilities, onFacilitySelect: the facility switcher. Build the rows with buildFacilityEntry from membershipProfiles(availableProfiles), so a patient row never shows.
  • user, onLogout, onAccountClick: the user menu.
  • topBar: the top bar. Omit it and the layout has no top bar.

Example: CMS Layout

// apps/cms/src/layouts/CMSLayout.tsx (shortened)
const switcherApps = buildApps(useAvailableApps())
const clinics = membershipProfiles(availableProfiles).flatMap((profile) =>
  (profile.facilities || []).map((facility) => buildFacilityEntry({ /* ... */ })),
)

<MicroAppLayout
  apps={switcherApps}
  appName="CMS"
  appTheme="cms"
  menuItems={menuItems}
  selectedFacility={selectedFacility || undefined}
  availableFacilities={clinics}
  onFacilitySelect={handleFacilitySelect}
>
  <RequireAccess featureFlag={APP_VISIBILITY_FLAGS.SHOW_CMS_APP} fallback={/* ... */}>
    {children}
  </RequireAccess>
</MicroAppLayout>

A facility switch inside an app calls the shared identityAuthService.switchFacility. It refreshes the cached data and keeps the user on the page.

Module Federation Routing

The apps do not use Module Federation. The shell imports each app as a workspace package and loads it with React.lazy (apps/shell/src/components/lazyApps.ts):

// apps/shell/src/components/lazyApps.ts
export const CMSApp = lazy(() => import("@kulcare/cms/src/App"));
export const GrowthApp = lazy(() => import("@kulcare/growth/src/App"));
export const ConnectApp = lazy(() => import("@kulcare/connect/src/App"));
export const ReportsApp = lazy(() => import("@kulcare/reports/src/App"));
export const HospitalApp = lazy(() => import("@kulcare/hospital/src/App"));
export const ResearchApp = lazy(() => import("@kulcare/research/src/App"));

AppRoutes wraps each app in LazyBoundary, a Suspense with a loader.

How Micro-Frontends Handle Routing

Each app has two modes. isStandaloneEnvironment() from @kulcare/ui-kit tells them apart. The shell calls markAsFederated('shell') in main.tsx before it renders.

  • Inside the shell: the app returns its routes only. The shell gives it the router, the query client, the theme and the locale.
  • Standalone (pnpm dev:cms and the like): the app adds its own BrowserRouter and providers.
// apps/cms/src/App.tsx (shortened)
function App() {
  if (!isStandaloneEnvironment()) {
    return <CMSRoutes />
  }
  return (
    <BrowserRouter>
      <QueryClientProvider client={queryClient}>
        <CMSRoutes />
      </QueryClientProvider>
    </BrowserRouter>
  )
}

The routes inside an app are relative to its mount path. For example, queue inside the CMS app is /cms/queue, and / inside the CMS app redirects to queue.

MicroAppLayout also reads isStandaloneEnvironment(). A standalone app shows no app switcher rail, because the other apps are not there.

Shell Integration

// apps/shell/src/components/AppRoutes.tsx (shortened)
<Route
  path="/cms/*"
  element={
    <ProtectedRoute>
      <LazyBoundary>
        <CMSApp />
      </LazyBoundary>
    </ProtectedRoute>
  }
/>

Important: The /* Pattern

Mount each app with a /* suffix, for example /cms/*. The suffix gives the rest of the path to the app's own routes.

1. Programmatic Navigation

Use useNavigate from react-router-dom. Inside an app, a relative path stays in the app. An absolute path goes through the shell router.

const navigate = useNavigate()

navigate('patients')                      // inside the CMS: /cms/patients
navigate('/connect/invites')              // another app
navigate(`/select-profile?${new URLSearchParams({ next: here })}`)  // the picker, with a return path

Shared ui-kit components use useRouterSafe(). It returns a real navigate inside a router and a fallback outside one.

Use Link and NavLink from react-router-dom. Do not use <a href> for a route in the same app, because it reloads the page.

3. Cross-Module Navigation

All apps share one browser router in the shell. An absolute path to another app works from any app:

navigate('/cms/patients/123')

Do not send a user to / to reach Connect. / redirects to /connect, so go to /connect directly.

Route Configuration

Current Route Structure

The tree in the Overview is the current route structure. AppRoutes.tsx and App.tsx are the source of truth. Each micro-frontend routes inside its own path, for example /cms/patients/:id inside the CMS app.

Feature Flag Controlled Routes

Two kinds of gate exist:

  • The route is not mounted. /research/* is in the route table only when SHOW_RESEARCH_APP is on. When it is off, the catch-all sends the user to /connect.
  • The route is mounted, and a gate shows a message. /hsp/* wraps the app in RequireAccess featureFlag={APP_VISIBILITY_FLAGS.SHOW_HOSPITAL_APP}. The CMS and Growth layouts wrap their content in RequireAccess with SHOW_CMS_APP and SHOW_GROWTH_APP. A user without the flag sees "not available".
// apps/shell/src/components/AppRoutes.tsx (shortened)
{appVisibility.research && (
  <Route
    path="/research/*"
    element={<ProtectedRoute requireClinic={false}><ResearchApp /></ProtectedRoute>}
  />
)}

A practice flag loads after the switch into that practice. AutoSelectFacility and the picker wait for the flags (loadTenantFeatureFlags), so a gated page does not show "not available" first.

Best Practices

1. Route Organization

// Group related routes
const patientRoutes = (
  <>
    <Route path="/patients" element={<PatientList />} />
    <Route path="/patients/new" element={<NewPatient />} />
    <Route path="/patients/:id" element={<PatientDetail />} />
    <Route path="/patients/:id/edit" element={<EditPatient />} />
  </>
);

2. Error Boundaries

<ErrorBoundary>
  <Routes>
    {/* routes */}
  </Routes>
</ErrorBoundary>

3. Loading States

<Suspense fallback={<ModuleLoader />}>
  <Routes>
    {/* lazy loaded routes */}
  </Routes>
</Suspense>

4. Route Guards

Use the shell ProtectedRoute. Do not write a new guard that sends the user to the picker or to /signin. Such a guard drops the return path.

// A page that needs only the user
<ProtectedRoute requireClinic={false}>
  <PreferencesPage />
</ProtectedRoute>

// A page that needs a facility: the guard selects one silently or opens the picker
<ProtectedRoute>
  <UsersPage />
</ProtectedRoute>

When you send a user to the picker or to sign-in from code, pass the page in next:

navigate(`/select-profile?${new URLSearchParams({ next: here })}`)

5. Consistent URL Patterns

/resource              // List view
/resource/new          // Create new
/resource/:id          // Detail view
/resource/:id/edit     // Edit view
/resource/:id/action   // Specific action

Module-Specific Routing

Growth Module Example

// apps/growth/src/App.tsx (shortened)
<Routes>
  <Route path="lp/:slug" element={<LazyPublicLandingPageView />} />
  <Route
    path="/*"
    element={
      <GrowthLayout>
        <Routes>
          <Route path="/" element={<Navigate to="dashboard" replace />} />
          <Route path="dashboard" element={<LazyDashboardPage />} />
          <Route path="website" element={<LazyWebsitePage />} />
          <Route path="referrals" element={<LazyReferralsPage />} />
        </Routes>
      </GrowthLayout>
    }
  />
</Routes>

Each app builds its own sidebar menu from the user's permissions, the practice's feature flags and its own needs. It passes the menu to MicroAppLayout as menuItems.

The App Switcher Rail

The rail is the icon grid at the top of the sidebar (packages/ui-kit/src/components/AppSwitcher.tsx).

  • APP_ROSTER lists the apps: CMS, Growth, Reports, Connect, Hospital and Research. It has no Dashboard entry, because / redirects to /connect. A Dashboard entry would be a second link to Connect.
  • An app layout passes apps={buildApps(useAvailableApps())}. useAvailableApps (@kulcare/auth-identity) is the one rule for every list of app links. It mirrors the shell route guards, so a link shows only when its route lets the user in:
    • CMS and Hospital: the SHOW_*_APP flag and an open facility. A facility is open when a practice is selected, or when the user is a member of a practice that is not cancelled.
    • Growth: the flag, an open facility and a key in the Marketing area (AREA_KEYS.marketing).
    • Reports: the flag, an open facility and a key in REPORTS_APP_KEYS.
    • Research: the flag only (its route has requireClinic={false}).
    • Connect: always on, because no flag gates it.
    • The key checks apply when a practice is selected. While its permissions load, Growth and Reports stay off. With no practice selected, the route sends the user to the facility picker before it checks the keys, so the rule does the same.
  • When you change a route guard in AppRoutes.tsx or ProtectedRoute.tsx, change useAvailableApps too. A link that the guard refuses sends the user back to /connect with no message (#2636).
  • Each app reads the flag that its gate reads. RequireAccess gates CMS, Growth, Reports and Hospital, and reads the auth store (a tenant flag or a user flag). That is the default of the hook. AppRoutes mounts /research on the shell's own FeatureFlagContext, so the shell's DashboardSidebar passes flagOverrides: { SHOW_RESEARCH_APP } from it.
  • The rail always shows the current app (currentApp, or appTheme when currentApp is not set), even when its flag reads false.
  • With no apps, the rail uses a fallback list that has only Connect. Every layout passes apps, so the fallback is a safety net only.
  • Connect turns the rail off (showAppSwitcher={false}). Its sidebar has an "Other apps" group, built from the same buildApps(useAvailableApps()) list. A vendor with no facility gets no group, unless Research is on.

Micro-Frontend Navigation

// An app menu (shortened)
const menuItems: MenuItem[] = [
  { key: 'queue', label: 'Queue', icon: <Users />, path: '/cms/queue' },
  { key: 'patients', label: 'Patients', icon: <User />, path: '/cms/patients' },
].filter((item) => hasPermission(item.key))

Testing Routes

Route Testing Checklist

  • All routes load without errors
  • Protected routes redirect when not authenticated
  • Deep links work correctly
  • Browser back/forward navigation works
  • Route parameters are handled correctly
  • 404 page shows for unknown routes
  • Cross-module navigation works

Example Route Tests

The shell has tests for each rule on this page:

  • apps/shell/src/components/__tests__/AppRoutes.test.tsx mounts the real route table. It checks the user-only pages, the facility pages, / and an unknown path.
  • apps/shell/src/components/__tests__/ProtectedRoute.test.tsx checks next, the silent switch and the picker redirect.
  • apps/shell/src/services/identity/__tests__/postAuth.test.ts checks finishSignIn and signInTarget.
  • packages/auth-identity/src/__tests__/safeReturnPath.test.ts checks the return path rules.
  • packages/ui-kit/src/components/__tests__/AppSwitcher.test.ts checks the rail roster. apps/shell/src/__tests__/appSwitcherFlags.test.tsx and each app layout test check the rendered rail.

Troubleshooting

Common Issues

  1. Infinite redirect loops

    • Check authentication state
    • Check that a page sends the user to the picker only through ProtectedRoute
  2. Routes not matching

    • Ensure /* for micro-frontend routes
    • Check route order (specific before generic)
  3. State lost on navigation

    • Use React Router's location state
    • Consider session storage for persistence
  4. App not loading

    • Check the lazy import in apps/shell/src/components/lazyApps.ts
    • Check that the app returns its routes only when isStandaloneEnvironment() is false
  5. No app switcher rail in a test

    • MicroAppLayout hides the rail in a standalone app. Call markAsFederated('shell') in the test, as main.tsx does

Future Enhancements

1. Route Preloading

// Preload routes on hover
onMouseEnter={() => {
  import('./modules/patients');
}}

2. Analytics Integration

// Track route changes
useEffect(() => {
  analytics.page(location.pathname);
}, [location]);

3. Breadcrumb Generation

// Auto-generate from route config
const breadcrumbs = generateBreadcrumbs(location.pathname);

Summary

  • The shell router signs the user in and decides if a page needs a facility.
  • Every sign-in ends on a safe next, or on /connect.
  • A page that needs a facility selects one silently, or opens the picker with next.
  • Each micro-frontend routes inside its own path.

On this page