React, Typescript, Tailwind CSS
Micro-Frontend Architecture Without Duplicating Your React Code
Micro-frontends sound great on paper: split a large frontend into smaller applications, let teams deploy independently, and reduce the blast radius of changes.
Then the first practical question appears:
Are we going to duplicate the same React code across every micro-frontend?
You shouldn't. A good micro-frontend architecture separates deployment boundaries from code-sharing boundaries. They are two different lines, and most of the pain comes from drawing them as one.
The wrong mental model
A common implementation looks like this:
student-app/
components/
auth/
api/
theme/
teacher-app/
components/
auth/
api/
theme/
admin-app/
components/
auth/
api/
theme/
The applications are technically independent, but the same components, authentication logic, API clients and utilities slowly get copied between repositories.
You end up with three versions of the same button, three authentication implementations and three different ways of handling an API error.
That is not modularity. That is distributed duplication.
The tell is what happens when something has to change. A token refresh bug is found in the student app. Who fixes it in the other two? When does that land? Did the teacher app already fix it differently six months ago? Nobody set out to build three auth clients - they arrived one copy-paste at a time, and now the cost of a single-line fix is three reviews, three releases and one team that forgets.
A better model
Keep the applications independently deployable, but extract reusable React code into shared packages.
apps/
student/
teacher/
admin/
packages/
ui/
auth/
api-client/
hooks/
config/
analytics/
domain/
Each frontend remains an application boundary. The shared packages become your code boundary.
import { Button } from "@platform/ui";
import { useCurrentUser } from "@platform/auth";
import { api } from "@platform/api-client";
export function Dashboard() {
const user = useCurrentUser();
return (
<Button
onClick={() => api.events.track("dashboard_clicked")}
>
Welcome {user.name}
</Button>
);
}
The exact same React component can now be consumed by multiple applications without copying it. Fix the token refresh once, and every application picks it up on its next build.
Micro-frontend does not mean micro-repository
This distinction matters more than almost anything else in the setup.
You can have multiple independently deployed React applications while still having one repository containing shared packages. For many teams, that is the cleanest micro-frontend architecture available.
repo/
├── apps/
│ ├── admin
│ ├── customer
│ └── partner
│
└── packages/
├── ui
├── auth
├── api
├── telemetry
└── shared
Tools such as pnpm workspaces, Turborepo or Nx make this structure straightforward to manage. The applications still build separately:
pnpm --filter admin build
pnpm --filter customer build
pnpm --filter partner build
A change to the customer application does not require redeploying admin. But both applications still consume:
import { DataTable } from "@company/ui";
One repository is not one deployment
People conflate these constantly. A repository is where code lives and how it is reviewed. A deployment is what ships and when. Nothing about a shared repository forces a shared release - the build tooling decides that, and every workspace tool listed above can build and ship one app without touching the others.
Share capabilities, not entire applications
There is an important boundary here. Not everything should become shared.
Good candidates include design-system components, authentication clients, API clients, feature flags, analytics, error handling, common hooks, configuration, domain types and validation schemas. What they have in common is that they should be consistent - a button that looks different in the admin app is a defect, not a feature.
Be more careful with sharing business screens. For example:
packages/ui/Button
is usually safe. But:
packages/shared/EntireCustomerDashboard
may indicate that the application boundaries are becoming meaningless. If the customer dashboard lives in a shared package, the customer team no longer owns the customer experience - they own a directory, and every change they make ships to applications they do not run.
A useful test before you promote something into packages/: who gets paged when it changes? If the answer is "everyone", it is shared infrastructure and belongs there. If the answer is "one team, about their own roadmap", it belongs inside that team's application.
The goal is not maximum reuse. The goal is stable reuse.
What about React itself?
React should normally be treated as shared platform infrastructure rather than bundled independently into every runtime-loaded micro-frontend.
When using runtime composition approaches such as Module Federation, React and React DOM can typically be configured as shared singleton dependencies. Conceptually:
shared: {
react: {
singleton: true
},
"react-dom": {
singleton: true
}
}
This avoids situations where the host loads one React runtime while a remote application loads another incompatible instance. Two React copies in one page is a specific, memorable class of bug: hooks throw invalid-hook-call errors, context providers stop reaching consumers that look like they are inside them, and instanceof checks across the boundary quietly fail. The symptom rarely names the cause.
For build-time shared packages in a monorepo, your workspace dependency strategy achieves the same architectural goal much more simply - a single hoisted React in the lockfile, with React itself declared as a peer dependency of your shared packages rather than a direct one.
Build-time sharing vs runtime sharing
There are really two different forms of micro-frontend architecture, and they solve different problems.
Build-time composition
Applications consume shared packages during compilation.
@company/ui
@company/auth
@company/api
This is simple, predictable and usually the place I would start. Version skew is impossible because there is only one version at the moment of the build, and the type checker sees across the whole boundary.
Runtime composition
One application dynamically loads another application's frontend bundle.
Shell
├── Account Remote
├── Billing Remote
└── Reporting Remote
Module Federation is commonly used for this model. Runtime composition becomes useful when teams genuinely require separate release cycles and ownership boundaries - when "wait for the next platform build" is an unacceptable answer.
But it adds complexity:
- dependency compatibility
- remote availability
- version coordination
- observability
- fallback behaviour
- routing ownership
- shared state
- authentication propagation
Every item on that list is a distributed-systems problem you have moved into the browser. Don't introduce those problems unless you actually need the organisational independence they provide.
Runtime composition is an organisational decision
The question is not "would this be a nicer architecture". It is "do we have teams whose release cadences genuinely cannot be coordinated". If three teams sit in the same planning cycle and ship on the same day, runtime composition buys you nothing and costs you a whole new failure mode - what the shell renders when a remote is down.
Keep shared state small
Another common mistake is turning the micro-frontend shell into a giant global state container. Every application reaches into it, and within a year the shell is coupled to every domain in the business.
Instead of sharing application state, share platform context:
type PlatformContext = {
userId: string;
tenantId: string;
locale: string;
accessToken: string;
};
Then each application owns its own domain state.
The rule of thumb is that shared state should be small, stable and read-mostly. Who you are and which tenant you are in changes rarely and is needed everywhere. Which row is selected in the billing table changes constantly and is needed in exactly one place. The moment a second application needs to read another application's working state, you have found either a missing API or a missing boundary - not a reason to enlarge the shell.
This dramatically reduces coupling between applications.
The architecture I usually prefer
For most React platforms, I would start here:
The important thing is that application independence and code reuse are not mutually exclusive. You can have both.
The principle
Micro-frontends should give you independent ownership and deployment. They should not force you to rewrite the same React code five times.
A useful rule is:
Separate what changes independently. Share what should remain consistent.
Your navigation shell may be shared. Your design system should probably be shared. Your authentication implementation should definitely not exist in six slightly different versions. But the business capabilities that evolve independently should remain inside their respective applications.
That balance is where micro-frontends start becoming useful rather than simply becoming another layer of frontend complexity.
Micro-frontends are not about creating more applications. They are about creating better boundaries.

