What is a micro-frontend?
A micro-frontend is an independently built and owned piece of a larger user interface. You can think of it as a microservice that lives in the browser (or in the app): it has its own codebase, its own team, and, ideally, its own build and deployment pipeline. Several of these pieces are then composed into one seamless experience for the user.
A typical e-commerce page shows the idea well. On a single product page you might have:
- A product team that owns the product details and gallery
- A checkout team that owns the basket and the buy button
- A recommendations team that owns the “you may also like” section
- A search team that owns the search bar and results
The user sees one page. Behind it, four teams work on four codebases, each with its own mission. Each team can build, test, and release its part without waiting for the others.
Monolithic frontend vs micro-frontends
In a monolithic frontend, everything (accounts, credit, payments, offers, and so on) lives inside a single application. It is straightforward at first, but it brings two well-known problems as the app grows:
- One failure can bring everything down. If one feature crashes, the whole application can be affected.
- Teams block each other. If the credit feature depends on code from the accounts feature, the developer working on credit may have to wait until the accounts developer finishes.
With micro-frontends, each feature is its own application. The accounts team and the credit team work independently. If the credit part breaks, the rest of the site can keep working, provided you have added sensible fallbacks.
| Monolithic frontend | Micro-frontends | |
|---|---|---|
| Codebase | One large codebase | Several smaller codebases |
| Team workflow | Teams share code and often wait for each other | Teams work independently on their own parts |
| Deployment | Everything is released together | Each part can be released on its own |
| Failure impact | A single failure can affect the whole app | Failures can be isolated to one part |
| Technology | One framework for everything | Different frameworks are possible (with trade-offs) |
| Complexity | Simple to start, harder as it grows | More setup up front, easier to scale teams |
Benefits
- Scalability across teams. Split the product into business domains (products, cart, transactions) and give each to a team that can move at its own pace.
- Reusability. A micro-frontend, such as a product widget or a login form, can be reused in other applications.
- Reliability. If one micro-frontend fails to load, the rest of the application can keep working. This does not happen automatically. You need fallbacks and error handling, which we cover below.
- Technology flexibility. One team can use React while another uses a different framework. This is possible, but mixing frameworks adds weight and complexity, so many companies deliberately standardise on one.
- Independent pipelines. Each part can have its own CI/CD, tests, and deployment process, which shortens release cycles.
Drawbacks and challenges
- Higher complexity and cost. More repositories, more pipelines, and more infrastructure. For a simple application that is not complex, this is extra overhead with little return.
- Hidden coupling. If micro-frontends depend heavily on each other, you get a distributed monolith: all the complexity of separate apps with none of the independence.
- Duplicate dependencies and larger bundles. If every micro-frontend ships its own copy of React and other libraries, users download more code. Shared dependencies must be configured carefully.
- Consistent user experience. Separate teams can drift apart visually. A shared design system is close to essential.
- Shared state and communication. Deciding how parts talk to each other (login state, cart contents, events) needs deliberate design.
- Testing and monitoring. Each part can pass its own tests, yet the combined result can still break. Integration testing and tracing across parts matter.
When should you use micro-frontends?
Micro-frontends solve an organisational problem more than a technical one. They make the most sense when:
- Several teams work on one product and keep getting in each other’s way
- The application is large and can be split along clear business domains
- Different parts need to be released on different schedules
- You are gradually replacing an old application piece by piece
When to skip them: a small team, a small or medium application, or a product where everything changes together. A well-organised single codebase, or a modular monolith, is simpler, cheaper, and faster to work with.
Ways to build micro-frontends
There is no single way to do this. These are the most common approaches:
| Approach | How it works | Trade-off |
|---|---|---|
| iframes | Each part runs in its own embedded page | Strong isolation, but awkward for layout, routing, and shared state |
| Web Components | Each part is a custom element loaded on the page | Framework-neutral, but needs extra work for shared dependencies |
| Shared npm packages | Parts are published as packages and combined at build time | Simple, but every change needs a rebuild and redeploy of the host |
| Module Federation | Parts are loaded at runtime from separately deployed builds | True independent deployment, but needs careful version and dependency management |
The rest of this guide focuses on Webpack Module Federation, which is the most popular runtime approach for React applications.
Hands-on: React and Webpack Module Federation
We will build two small React applications. app1 is the remote: it exposes a Button component. app2 is the host: it loads and displays that button at runtime, without bundling it.
Tooling changes quickly. This example uses plain Webpack 5, which includes Module Federation built in. Newer options such as Module Federation 2 and Rspack or Vite plugins exist. The core ideas (names, exposes, remotes, shared) stay the same. Check the official documentation for the latest setup steps.
Step 1: Project layout
mfe-demo/
app1/ (remote: exposes Button, runs on port 3001)
app2/ (host: consumes Button, runs on port 3002)
Inside each folder, run:
npm init -y
npm install react react-dom
npm install -D webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-react
Add a start script to each package.json:
"scripts": {
"start": "webpack serve"
}
Step 2: Build app1 (the remote)
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
</body>
</html>
import React from "react";
export default function Button() {
return <button>Button from App 1</button>;
}
import React from "react";
import Button from "./Button";
export default function App() {
return (
<div>
<h1>App 1</h1>
<Button />
</div>
);
}
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")).render(<App />);
// Load the app asynchronously so shared modules can be negotiated first
import("./bootstrap");
Now the Webpack configuration, including the Module Federation plugin:
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
mode: "development",
entry: "./src/index.js",
devServer: {
port: 3001,
},
output: {
publicPath: "auto",
},
resolve: {
extensions: [".js", ".jsx"],
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: "babel-loader",
options: {
presets: ["@babel/preset-react"],
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: "app1",
filename: "remoteEntry.js",
exposes: {
"./Button": "./src/Button",
},
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
},
}),
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
],
};
What the Module Federation options mean:
name: the unique name of this micro-frontend. Other apps use it to refer to this one. It is case sensitive.filename: the manifest file Webpack generates (remoteEntry.js). It lists everything this app makes available to others.exposes: the modules this app shares with the outside world, mapped from a public name (./Button) to a file path.shared: libraries that should be loaded only once across all micro-frontends. Marking React as a singleton prevents two copies of React from running on the same page.
Run it with npm start and open http://localhost:3001. You should see App 1 with its button. You can also open http://localhost:3001/remoteEntry.js to see the generated manifest.
Step 3: Build app2 (the host)
Create app2 with the same files (public/index.html, src/index.js, src/bootstrap.js, and the same Babel rule), then change the following.
devServer: {
port: 3002,
},
// ...
new ModuleFederationPlugin({
name: "app2",
remotes: {
app1: "app1@http://localhost:3001/remoteEntry.js",
},
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
},
}),
Here remotes tells app2 where to find app1. The format is name@url-to-remoteEntry. The key on the left (app1) is the prefix you will use in your imports. app2 exposes nothing, so it has no exposes section.
import React, { Suspense } from "react";
// Loaded at runtime from app1
const Button = React.lazy(() => import("app1/Button"));
export default function App() {
return (
<div>
<h1>App 2</h1>
<Suspense fallback={<p>Loading...</p>}>
<Button />
</Suspense>
</div>
);
}
Using React.lazy with Suspense is the usual way to render a remote component, because it arrives asynchronously over the network. The remote component must use export default for this to work.
Step 4: Run and test
- Start app1 first (
npm startin the app1 folder). The host needs the remote to be available. - Start app2 and open
http://localhost:3002. You will see the button that comes from app1, rendered inside app2. - Now open
app1/src/Button.jsand change the text, for example to “Button v2”. Refresh the app2 page. The new text appears, and you did not rebuild or redeploy app2.
That last step is the whole point of runtime composition. When the page loads, app2 fetches remoteEntry.js from app1, so it always gets the latest deployed version of the remote.
Why the bootstrap file matters
You may have wondered why index.js only contains import("./bootstrap"). Module Federation has to negotiate shared libraries (like React) between apps before any of your code runs. If your entry file imports React directly, it tries to use it immediately, before that negotiation finishes, and you get the error “Shared module is not available for eager consumption”. Loading the real app through a dynamic import() creates an asynchronous boundary that gives Webpack time to set everything up. This is why so many Module Federation projects have both an index and a bootstrap file.
Using TypeScript
With TypeScript, the compiler does not know that app1/Button exists, because it is resolved at runtime. Add a declaration file in the host so imports type-check:
declare module "app1/Button";
This is the simplest form and treats the module as untyped. For stronger typing, declare the exact shape of each exposed module, or use tooling that generates types from remotes.
Protect the host from a failing remote
Runtime loading means a remote can be down, slow, or broken. Wrap remote components in an error boundary so a failure shows a friendly message instead of crashing the page:
import React from "react";
export default class RemoteBoundary extends React.Component {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render() {
if (this.state.failed) {
return <p>This section is temporarily unavailable.</p>;
}
return this.props.children;
}
}
<RemoteBoundary>
<Suspense fallback={<p>Loading...</p>}>
<Button />
</Suspense>
</RemoteBoundary>
This is what turns the claim “one failure does not break the whole app” from a theory into reality.
Combining several micro-frontends in one container app
A more realistic setup uses a container (also called a shell) that assembles several micro-frontends into one application. Imagine a small learning site with three apps:
- container: the main app users open. It provides navigation and loads the others.
- home: a remote that exposes a
HomePagecomponent. - courses: a remote that exposes a
Coursescomponent.
Each of the three has its own webpack.config.js with a Module Federation plugin. The rule is simple:
- A remote uses
name,filename, andexposes. - The container uses
remotesto list every micro-frontend it consumes. - All of them declare the same
sharedlibraries.
new ModuleFederationPlugin({
name: "home",
filename: "remoteEntry.js",
exposes: {
"./HomePage": "./src/components/HomePage",
},
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
},
}),
new ModuleFederationPlugin({
name: "container",
remotes: {
home: "home@http://localhost:3001/remoteEntry.js",
courses: "courses@http://localhost:3002/remoteEntry.js",
},
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
},
}),
Then the container renders both remotes and switches between them with React Router:
import React, { Suspense } from "react";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
const HomePage = React.lazy(() => import("home/HomePage"));
const Courses = React.lazy(() => import("courses/Courses"));
export default function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>{" | "}
<Link to="/courses">Courses</Link>
</nav>
<Suspense fallback={<p>Loading...</p>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/courses" element={<Courses />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Run the two remotes first, then start the container. In the browser, the user only visits the container’s address. Clicking “Courses” shows the courses micro-frontend, and clicking “Home” shows the home one, all under the same address. In development the remotes still run on their own ports in the background. In production each one is deployed to its own location, and the user still sees a single site.
Names must match exactly. The name after remotes (home), the name in the remote’s own config, and the import path (home/HomePage) all have to line up, including capital letters. Most first-time errors come from a mismatch here.
A note on Create React App. Older tutorials add Module Federation to Create React App projects by overriding its hidden Webpack configuration, which needs extra scripts and helper files. Create React App is no longer actively recommended for new projects, and a direct Webpack (or Rspack or Vite) setup like the one above is simpler and easier to maintain.
Micro-frontends in mobile development
The word “frontend” does not only mean web. In a mobile app, the same pain appears: a big codebase, many teams, slow builds, and features that step on each other. Mobile teams solve it with the same core idea, splitting the app into self-contained feature modules, but the details differ in one important way.
Compile-time vs runtime composition
On the web, micro-frontends are usually composed at runtime: the host loads the latest version of each piece when the page opens, so a team can deploy without touching anyone else. On mobile, apps normally go through an app store review, so most “micro-frontend” architectures are compile-time modules: separate packages with clear boundaries that are combined when the app is built. You still get team independence and cleaner code, but not independent live deployment.
| Platform | Typical approach | Composition |
|---|---|---|
| Flutter | Local packages (one per feature), routes exposed by each module | Compile time |
| Android (native) | Gradle multi-module projects, dynamic feature modules | Compile time, optional on-demand delivery |
| iOS (native) | Swift packages or separate frameworks | Compile time |
| React Native | Monorepo packages, or Module Federation with a tool such as Re.Pack | Compile time, or runtime with Module Federation |
For React Native, Re.Pack (a Webpack and Rspack based bundler that can replace Metro) brings Module Federation to mobile. It is used to build “super apps”: a main host app that loads separately developed mini apps, sometimes maintained by different teams or companies. This gives true runtime composition on mobile, but Apple and Google both have rules about downloading and running code after an app has been reviewed. Check the current store policies before shipping runtime-loaded modules.
Whichever platform you use, the goals are the same: clear module boundaries, one owner per module, minimal coupling, and a small shared core.
Hands-on: modular Flutter app with go_router
Flutter does not have micro-frontends in the browser sense, but its package system makes a very similar architecture easy. We will build an app with four modules, each in its own package:
- core: shared code, such as app state
- login: the login screen
- dashboard: the home screen after login
- profile: the profile screen
Each feature module owns its screens and its routes. The main app only assembles them. This lets different developers work on different modules with minimal conflicts, and it keeps coupling low.
Step 1: Create the project and modules
flutter create micro_frontend_app
cd micro_frontend_app
mkdir modules
flutter create --template=package modules/core
flutter create --template=package modules/login
flutter create --template=package modules/dashboard
flutter create --template=package modules/profile
Each command creates a Flutter package containing some sample code. You can delete the sample code and replace it with your own.
Step 2: Register the modules in the main app
Open the main app’s pubspec.yaml and add each module as a path dependency:
dependencies:
flutter:
sdk: flutter
core:
path: modules/core
login:
path: modules/login
dashboard:
path: modules/dashboard
profile:
path: modules/profile
Add the routing package to the main app and to each module that defines routes:
flutter pub add go_router
cd modules/login && flutter pub add go_router
cd ../dashboard && flutter pub add go_router
cd ../profile && flutter pub add go_router
Modules that use the shared state also need to depend on core. In modules/login/pubspec.yaml and modules/dashboard/pubspec.yaml:
dependencies:
flutter:
sdk: flutter
# go_router is added here by: flutter pub add go_router
core:
path: ../core
A module can only import what it declares in its own pubspec.yaml. If you forget to add a dependency, you will see an import error even though the package exists elsewhere in the project.
Step 3: Shared state in the core module
class AppState {
static String userId = '';
static String username = '';
}
export 'app_state.dart';
This simple static class is enough for a demo. Later in this guide we cover better options for real apps.
Step 4: The login module
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:core/core.dart';
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Center(
child: ElevatedButton(
onPressed: () {
AppState.username = 'Alex';
context.go('/dashboard');
},
child: const Text('Login'),
),
),
);
}
}
Now the module declares its own routes. This is the key idea: the module tells the app which routes it provides, and the app does not need to know anything about its screens.
import 'package:go_router/go_router.dart';
import 'login_screen.dart';
class LoginRoutes {
static const String path = '/login';
static final List<RouteBase> routes = [
GoRoute(
path: path,
name: 'login',
builder: (context, state) => const LoginScreen(),
),
];
}
export 'login_routes.dart';
Step 5: The dashboard and profile modules
The dashboard reads the username from the core module and offers a button that opens the profile screen.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:core/core.dart';
class DashboardScreen extends StatelessWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dashboard')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Welcome, ${AppState.username}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.push('/profile'),
child: const Text('Go to profile'),
),
],
),
),
);
}
}
Create dashboard_routes.dart and dashboard.dart the same way as for login (path /dashboard, exporting DashboardRoutes.routes). Build the profile module the same way, with a simple ProfileScreen and ProfileRoutes for the path /profile.
Step 6: Assemble everything in the main app
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:login/login.dart';
import 'package:dashboard/dashboard.dart';
import 'package:profile/profile.dart';
final GoRouter router = GoRouter(
initialLocation: LoginRoutes.path,
routes: [
...LoginRoutes.routes,
...DashboardRoutes.routes,
...ProfileRoutes.routes,
],
);
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(routerConfig: router);
}
}
Run the app. You land on the login screen, tap the button, arrive on the dashboard with your username, and can open the profile screen.
Step 7: Add a new module in minutes
The real payoff of this structure is how easily it grows. To add a Settings feature:
- Create the package:
flutter create --template=package modules/settings - Run
flutter pub add go_routerinside the new module. - Add a
SettingsScreenand aSettingsRoutesclass with the path/settings, and export it fromsettings.dart. - Add the module as a path dependency in the main app’s
pubspec.yaml. - Add
...SettingsRoutes.routesto the router inmain.dart. - Add a button somewhere (for example an icon in the dashboard app bar) that calls
context.push('/settings').
Two practical tips. First, context.go() replaces the navigation stack, while context.push() adds a screen on top so the back button works. Use push when you want users to be able to go back. Second, after adding a new package or route, do a full restart. A hot reload will not pick up new dependencies, and you may see a “page not found” error until you restart.
A limit of this approach in Flutter: all modules end up inside one app, and Dart resolves a single version of each dependency for the whole project. Modules cannot each use a different version of the same package the way separate web micro-frontends can. Treat this as a modular architecture inspired by micro-frontends, not as independently deployable pieces.
Sharing state and communication
The hardest design question in any micro-frontend setup is how parts share information. A few principles help:
- Share as little as possible. Every shared thing is a form of coupling. Agree on a small, stable set: the current user, the auth token, the theme, and a few events.
- Prefer events and props over direct imports. On the web, custom browser events, a small event bus, or props passed down from the container keep parts loosely connected.
- Keep ownership clear. Each piece of data should have one module that owns and updates it.
- Use a proper state solution in apps. The static
AppStatein our Flutter demo does not notify widgets when values change. For real apps, put a state management solution (for example Riverpod, Bloc, or Provider) in the shared core module so every feature module reads and reacts to the same state. - Version your contracts. If a module exposes a component or route that others depend on, treat its public interface like an API and avoid breaking changes.
Best practices
- Split by business domain, not by technical layer. “Checkout” and “Search” are good boundaries. “All buttons” or “all API calls” are not.
- Give each module one owning team. Clear ownership is the main reason to use this architecture.
- Invest in a shared design system. A common set of components, colours, and spacing keeps the interface consistent.
- Share libraries carefully. Use singletons for frameworks like React, and agree on compatible versions across teams.
- Design for failure. Add loading states, error boundaries, and fallbacks for every remote piece.
- Automate integration testing. Test the assembled application, not just each piece on its own.
- Monitor the whole experience. Track performance and errors across all modules so you can see problems that appear only in combination.
- Start small. Extract one well-bounded feature first, learn from it, and then continue.
Common errors and how to fix them
| Problem | Likely cause | Fix |
|---|---|---|
| “Shared module is not available for eager consumption” | The entry file imports React directly | Move the app into bootstrap.js and load it with a dynamic import() from index.js |
| “Invalid hook call” or two copies of React | React is not shared as a singleton | Add shared with singleton: true for react and react-dom in every app |
| Remote module cannot be found | Name mismatch, wrong URL, or remote not running | Check names and capitalisation in name, remotes, and the import path; confirm the remote’s remoteEntry.js opens in the browser |
| Remote component does not render | Missing export default, or not wrapped in Suspense |
Export the component as default and wrap the lazy import in Suspense |
| Old version keeps appearing after a deploy | remoteEntry.js is cached |
Serve remoteEntry.js with no-cache or short-cache headers and use hashed names for other files |
| Works in development but fails in production | Hard-coded localhost URLs |
Use the real deployed URL of each remote, ideally from configuration |
| Flutter: “page not found” after adding a module | Hot reload does not load new packages or routes | Fully restart the app and confirm the route is added to the router |
| Flutter: cannot import a package inside a module | The dependency is missing from that module’s pubspec.yaml |
Add it to that module, for example with flutter pub add |
Frequently asked questions
What is a micro-frontend in simple words?
It is a way of building a large user interface out of smaller, independent applications, each owned by its own team, and combining them so the user sees one product.
Are micro-frontends the same as microservices?
They use the same idea, splitting a large system into independent parts, but micro-frontends apply it to the user interface, while microservices apply it to backend services. They are often used together.
What is Module Federation?
It is a Webpack feature (also supported by newer tools) that lets one application load code from another separately built and deployed application at runtime, while sharing common libraries such as React.
Can I mix React, Angular, and Vue in one app?
Technically yes, but it increases bundle size, complexity, and maintenance work. Most organisations choose one framework and use micro-frontends to split ownership, not to mix technologies.
Are micro-frontends good for small projects?
Usually not. The setup and maintenance cost is real, and a small team gains little from it. A well-structured single codebase is often the better choice.
Can I use micro-frontends in a mobile app?
You can use the same ideas. Flutter, Android, and iOS apps are commonly split into feature modules that are combined at build time. React Native can go further with Module Federation and tools such as Re.Pack, which enable runtime loading, subject to store policies.
Do I need a monorepo?
No. Micro-frontends can live in separate repositories or together in one monorepo. A monorepo makes sharing tooling and code easier, while separate repositories give teams more independence.
How do micro-frontends handle a shared login?
Usually the container app manages authentication and passes the user or token to each micro-frontend through props, shared state, or events, so individual parts do not each implement their own login.
Conclusion
Micro-frontends let large teams build one product as a set of independent parts, with separate codebases, separate releases, and failures that stay contained. On the web, Webpack Module Federation makes runtime composition practical, and in mobile development the same thinking shows up as modular feature packages, as in the Flutter example above.
They are not free. They add complexity, and they only pay off when the product and the organisation are big enough to need them. If you do adopt them, keep boundaries clean, share as little as possible, plan for failure, and start with one small feature. Try the React example first, since it shows the runtime idea clearly, and then apply the same principles to your own web or mobile project.