Building a Hospital ERP Frontend with a 9-Developer Team

The starting point
Buch International Hospital needed a modern ERP frontend covering patient management, billing, and inventory — replacing a legacy system the staff had outgrown. I joined as frontend team lead with three developers; by the end we were nine. Scaling the team turned out to be a harder engineering problem than the UI itself.
What does large-scale React architecture actually mean?
Not a framework choice. On a codebase this size, architecture is the set of decisions that let nine people change the same repository in the same week without breaking each other. Three questions cover most of it: where does a new file go, where does this piece of state live, and what stops a bad change reaching production. Everything below is our answer to those three, and the answers changed as the team grew.
Architecture before headcount
Adding developers to a codebase without conventions multiplies chaos rather than output. Before we grew past four people, we locked three things:
- A component library with clear ownership. Healthcare workflows reuse the same primitives constantly — data grids, patient cards, form sections with validation. Building these once in a shared
uilayer, with Material-UI as the base, cut feature development time by roughly 30%. - Feature folders with explicit boundaries. Patient management, billing, and inventory each owned their components, hooks, and API adapters. A developer could own a module end-to-end without stepping on another team's diffs.
- State rules everyone could recite. Server data lived in Redux Toolkit with RTK Query patterns; form state stayed local; global UI state was a thin slice. Arguments about "where does this state go" disappeared because the answer was written down.
How was the folder structure organised?
The tree below is the shape we settled on. The rule a new developer needs on day one is simply: feature code goes in its feature folder; something goes in shared/ only when two features already use it.
src/
features/
patients/
components/ PatientCard, PatientTable, AdmissionForm
hooks/ usePatientFilters, useAdmissionFlow
api/ patientsApi.ts (RTK Query slice)
types/ patient.ts (domain types + derived)
utils/ formatMrn.ts
routes/ PatientListPage, PatientDetailPage
index.ts public surface of the feature
billing/
inventory/
pharmacy/
shared/
ui/ Button, Modal, DataTable, FormField
hooks/ useDebounce, usePermission
lib/ apiClient, dateUtils, auditLogger
types/ ApiResponse, Paginated, Permission
app/
store.ts RTK store composition
router.tsx
providers.tsxTwo rules made this hold up under nine people. Features may not import from each other's internals — only from a feature's index.ts, which forced us to be deliberate about what each module actually exposed. And shared/ requires a second consumer: premature promotion into shared is how you get a "generic" component with eleven boolean props serving one caller.
The payoff was measurable in review. Once feature ownership was structural rather than social, merge conflicts dropped to near zero between teams, and a developer could work in billing for a month without ever reading pharmacy code.
Why RTK Query for server state?
The decision that removed the most code was separating server state from client state and refusing to let Redux hold both.
Before, we had the pattern most React codebases accumulate: thunks fetching data, reducers storing it, components dispatching on mount, and a long tail of manual loading and error flags. Roughly a third of our Redux code existed to re-implement caching badly.
RTK Query replaced all of it. Each feature owns an API slice, and cache invalidation is declared rather than orchestrated:
export const patientsApi = createApi({
reducerPath: "patientsApi",
baseQuery: authorisedBaseQuery,
tagTypes: ["Patient", "Admission"],
endpoints: (build) => ({
getPatients: build.query<Paginated<PatientSummary>, PatientFilters>({
query: (filters) => ({ url: "/patients", params: filters }),
providesTags: ["Patient"],
}),
admitPatient: build.mutation<Admission, AdmissionInput>({
query: (body) => ({ url: "/admissions", method: "POST", body }),
invalidatesTags: ["Patient", "Admission"],
}),
}),
});The rule the whole team could recite: server data lives in RTK Query, form state stays local with React Hook Form, and the Redux slice holds only genuine cross-cutting UI state — the active ward filter, sidebar collapse, the current shift. That slice ended up under two hundred lines across the entire application. Anything that arrived over the network never touched it.
Code review as the scaling mechanism
With nine developers of mixed seniority, review was where quality was actually enforced. We kept PRs small, required a checklist (responsive behavior, loading states, error states, permissions), and rotated reviewers so knowledge spread instead of pooling. The discipline felt slow in week one and saved us by month three — onboarding a new developer meant pointing at fifty well-reviewed PRs that showed the house style.
Concretely, the process was: PRs under roughly four hundred changed lines, two approvals for anything touching shared/ and one for feature-local work, and a rotating reviewer pairing so no two people reviewed only each other. Median time to first review was under three hours, which mattered more than any individual rule — a checklist nobody reaches for because the queue is two days deep is decoration.
The checklist itself stayed short enough to actually use:
- Loading, empty, and error states all render — not just the happy path
- Permissions checked at the component boundary, not only on the route
- No new state in the global slice without a comment justifying it
- Responsive down to 1280px, the resolution of the hospital's terminals
- Audit-relevant actions call the logger
Healthcare-specific constraints
Hospital software has failure modes that marketing sites do not:
- Data correctness over cleverness. A billing total that renders wrong is not a UI bug, it is an incident. We validated aggressively at the boundary and rendered explicit error states rather than optimistic guesses.
- Workflows over pages. Staff think in admission flows and discharge flows, not routes. Mapping UI structure to their mental model cut training time for hospital staff measurably — efficiency improved about 35% after rollout.
- Performance on modest hardware. Hospital terminals are not developer laptops. Virtualized tables and careful re-render control were necessities, not optimizations.
How do you handle permissions and audit logging in the UI?
These two requirements shaped more of the architecture than anything else on the list.
Permissions had to be declarative. Role checks scattered as inline conditionals are impossible to audit and impossible to test — and in a hospital, "can this user see this patient's record" is a question with legal weight. We centralised on a single hook plus a wrapper component:
const can = usePermission();
{can("billing:refund") && <RefundButton invoiceId={id} />}
// Or declaratively, for whole sections
<RequirePermission perm="patient:read" fallback={<AccessDenied />}>
<PatientRecord id={id} />
</RequirePermission>The rule was that every permission string lives in one typed union, so an invalid check fails at compile time rather than silently rendering nothing. The UI layer is a convenience, never the enforcement — the API validates independently, and we treated any screen where the frontend was the only gate as a defect.
Audit logging could not be left to developers to remember. Regulated environments need a record of who viewed and changed what, and "remember to call the logger" is not a control. We pushed it into the layers actions already flow through: the RTK Query base query logged every mutation with the endpoint, entity id, and user, and sensitive read screens logged on mount through a small hook.
useAuditLog({ action: "patient:view", entityId: patientId });Two lessons. Log the entity identifier, never the payload — a well-meant "log the whole object" turns your audit trail into a second copy of your patient database with weaker access controls. And make the log call fire-and-forget with its own error handling; an audit endpoint hiccup should never block a clinician from seeing a record.
What worked at three developers and broke at nine?
The honest part of this story is how much of the original setup did not survive growth.
Shared component ownership. At three people, anyone could change anything in shared/ui and we would notice in review. At nine, a well-intentioned tweak to DataTable broke three screens in features the author had never opened. Fix: shared components got explicit owners, a props-are-a-contract rule, and any breaking change required updating all consumers in the same PR.
Informal conventions. With three people the conventions lived in our heads and it worked fine. At six, new joiners copied whichever file they happened to open first, and we grew three competing patterns for form handling. Fix: written conventions with one canonical example per pattern, and reviewers pointing at the example rather than re-explaining.
One big Redux store. The original store held everything, which was legible at three developers and a merge-conflict magnet at nine — every feature touched the same file. Fix: the RTK Query split described above, plus feature-scoped slices composed in app/store.ts.
Everyone in every standup. A single standup with nine people meant most attendees listened to updates irrelevant to them for twenty minutes. Fix: module-level syncs with leads carrying cross-cutting issues up.
The generalisable point: the practices that scale are the ones that make the correct choice structural rather than remembered. Anything relying on everyone knowing something works until the day one person does not.
What went wrong: the abstraction we built too early
The most expensive mistake was a generic <ResourceTable> built in month two, designed to handle every list in the application through configuration. By month five it had nineteen props, four of them functions, and a switch statement handling special cases for patients, invoices, and inventory. Every new feature meant adding a branch to a component three teams depended on — the exact coupling the feature-folder structure existed to prevent.
We deleted it in month six and replaced it with a much smaller DataTable that handles sorting, virtualisation, and layout, plus per-feature table components composing it. Total code went up by roughly two hundred lines; the number of files a new list feature had to touch went from four to one, and cross-team review load dropped noticeably.
What I would do differently is apply the same bar to shared components that we applied to shared/ folders: build the specific thing twice, then extract what is genuinely common. We had that rule for utilities and never wrote it down for components.
What I would repeat
Locking architecture before scaling headcount; investing in the shared component layer early; treating code review as the primary teaching channel. The project delivered its 12-month roadmap on schedule with a team that had tripled in size — and the patterns are the same ones I describe in how I structure React components for teams of 10+.
The full project breakdown with results is in the case studies, and if you are scaling a frontend team right now, my services page describes how I take on exactly this kind of engagement.
Muzamal Ali — Senior Frontend Engineer & Team Lead
Senior Frontend Engineer with 5+ years building production React and Next.js applications. I've led teams of 3–9 developers across healthcare, aviation, AI, and SaaS platforms. Based in Pakistan, working async with European tech teams.
Working on something similar?
I help European tech teams ship better frontends.


