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
- Overview
- Routing Hierarchy
- Authentication Flow
- Protected Routes
- Layout Components
- Module Federation Routing
- Navigation Patterns
- Route Configuration
- Best Practices
- Navigation Menu Integration
- 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 trailFiles:
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:ProtectedRouteandAutoSelectFacility.apps/shell/src/components/lazyApps.ts: the lazy imports of the micro-frontends. They sit in their own module, so a test can mountAppRouteswith stubs.apps/shell/src/services/identity/postAuth.ts:finishSignIn,signInTargetandsilentSwitchTarget.
Layout Architecture
- Shell layout (
apps/shell/src/layouts/ShellLayout.tsx): an<Outlet />and nothing else. It draws no header and no sidebar. - App layouts: each app draws its own chrome with the shared
MicroAppLayoutfrom@kulcare/ui-kit. The shell account pages useDashboardSidebar, which also rendersMicroAppLayout. - 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.
finishSignInreads the new profile list from the auth store.- 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. - It goes to
signInTarget(next)withreplace.
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).
ProtectedRoutesends a signed-out user to/signin?next=<path, search and hash>.SignInPagereadsnext. On the unverified-login branch it passesnextto/identity/verify-otpin the router state.GuestRoutesends a signed-in visitor tosignInTarget(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.examplefails. 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:
| Prop | Default | Use |
|---|---|---|
requireClinic | true | The page needs a selected facility (a tenant token). |
requiredPermissions | none | Any of these permissions opens the page. |
deniedMessage | a subscription message | The 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:
- Signed out: go to
/signin?next=<the page>. requireClinicand no selected facility:- No facility choices: go to
/connect. - One facility in an open practice:
AutoSelectFacilityswitches into it, then the page renders. - Several facilities, or one in a suspended or cancelled practice: go to
/select-profile?next=<the page>.
- No facility choices: go to
requiredPermissionswhile the permissions load: render nothing.requiredPermissionsnot met: showUnauthorizedMessage.
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).
- It asks
ProtectedRouteto hold the page on a spinner. The new token alone would give the guard a selected profile, so the hold starts before the switch. - It calls the shared
switchFacilityonce, with the reasonauto_select. - It reads the practice's feature flags into the store (
loadTenantFeatureFlags). Without this step, a page gated on a practice flag shows "not available" untilFeatureFlagProviderrefetches. - 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
switchFacilitywith the page's query client, thenloadTenantFeatureFlags. Then it goes tosafeReturnPath(next)or/connect. - On a direct visit with no
nextand one open facility, it selects that facility after 1.5 seconds. Withnext, 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 anactionsslot. 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::SuspendedTenantGuardruns on everyShared::BaseControllerrequest. On a suspended or cancelled tenant,GETandHEADpass, and any other method gets 403TENANT_SUSPENDED. The payment path is open to a member withmanage_subscription.- A
read_onlysession on a practice that is active again gets 409PROFILE_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
- Guest routes:
GuestRoute. A signed-in visitor goes tosignInTarget(next). - Signed-in routes:
ProtectedRoute requireClinic={false}. No facility is needed. - Facility routes:
ProtectedRoute. A facility is selected silently or through the picker. - 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:
ProtectedRouteswitches 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_paymentand the member hasmanage_subscription(canPayToRestorein@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 showSuspendedPracticeBannerfrom it.- The main write actions are disabled while
useTenantAccess().isReadOnlyis true. Each one showsReadOnlyReasonfrom@kulcare/ui-kit("This practice is read-only while it is suspended.") and putsREAD_ONLY_REASONin itstitle:- 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 itstoastwithguardErrorToastsAfterTenantSuspended, 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 raiseskulcare-profile-switch-required, and the shell switches again into the same facility (useProfileSwitchRequired). The checkout does the same at once when verify saystenant_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.
| Layout | File | currentApp for the rail |
|---|---|---|
| Shell account pages | apps/shell/src/components/DashboardSidebar.tsx | none (the shell is not an app in the rail) |
| CMS | apps/cms/src/layouts/CMSLayout.tsx | cms |
| Growth | apps/growth/src/layouts/GrowthLayout.tsx | growth |
| Reports | apps/reports/src/layouts/ReportsLayout.tsx | reports |
| Connect | apps/connect/src/layouts/ConnectLayout.tsx | connect (rail off, see below) |
| Hospital | apps/hospital/src/layouts/HospitalLayout.tsx | hospital |
| Research | apps/research/src/layouts/ResearchLayout.tsx | research |
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 withbuildFacilityEntryfrommembershipProfiles(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:cmsand the like): the app adds its ownBrowserRouterand 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.
Navigation Patterns
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 pathShared ui-kit components use useRouterSafe(). It returns a real navigate inside a router and a fallback outside one.
2. Link Components
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 whenSHOW_RESEARCH_APPis 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 inRequireAccess featureFlag={APP_VISIBILITY_FLAGS.SHOW_HOSPITAL_APP}. The CMS and Growth layouts wrap their content inRequireAccesswithSHOW_CMS_APPandSHOW_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 actionModule-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>Navigation Menu Integration
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_ROSTERlists 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_*_APPflag 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.
- CMS and Hospital: the
- When you change a route guard in
AppRoutes.tsxorProtectedRoute.tsx, changeuseAvailableAppstoo. A link that the guard refuses sends the user back to/connectwith no message (#2636). - Each app reads the flag that its gate reads.
RequireAccessgates CMS, Growth, Reports and Hospital, and reads the auth store (a tenant flag or a user flag). That is the default of the hook.AppRoutesmounts/researchon the shell's ownFeatureFlagContext, so the shell'sDashboardSidebarpassesflagOverrides: { SHOW_RESEARCH_APP }from it. - The rail always shows the current app (
currentApp, orappThemewhencurrentAppis not set), even when its flag reads false. - With no
apps, the rail uses a fallback list that has only Connect. Every layout passesapps, 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 samebuildApps(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.tsxmounts the real route table. It checks the user-only pages, the facility pages,/and an unknown path.apps/shell/src/components/__tests__/ProtectedRoute.test.tsxchecksnext, the silent switch and the picker redirect.apps/shell/src/services/identity/__tests__/postAuth.test.tschecksfinishSignInandsignInTarget.packages/auth-identity/src/__tests__/safeReturnPath.test.tschecks the return path rules.packages/ui-kit/src/components/__tests__/AppSwitcher.test.tschecks the rail roster.apps/shell/src/__tests__/appSwitcherFlags.test.tsxand each app layout test check the rendered rail.
Troubleshooting
Common Issues
-
Infinite redirect loops
- Check authentication state
- Check that a page sends the user to the picker only through
ProtectedRoute
-
Routes not matching
- Ensure
/*for micro-frontend routes - Check route order (specific before generic)
- Ensure
-
State lost on navigation
- Use React Router's location state
- Consider session storage for persistence
-
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
- Check the lazy import in
-
No app switcher rail in a test
MicroAppLayouthides the rail in a standalone app. CallmarkAsFederated('shell')in the test, asmain.tsxdoes
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.