Micro frontends with Module Federation and Webpack 5
The release of Webpack 5 delivered something special besides performance improvements like improved tree-shaking and persistent caching across builds - an architectural possibility called Module Federation.
Module Federation (MF) enables applications to seamlessly consume and expose code in a way that hasn’t been possible before, paving the way for micro frontend composition in JS land and general federation of whatever you choose to pass through Webpack.
In this post we'll take a look into the world of Module Federation with Webpack 5 and how we can utilize this tool to create a foundation for multiple teams working on the same product.
Federated e-commerce application
To demonstrate how Module Federation works, lets get started tackling our simplified e-commerce scenario once again, but this time from React SPA land.
The final application consists of a landing page with a bunch of products, a minicart and a checkout page. What's special about it is that each page is a standalone, isolated micro frontend.
Monorepo / Yarn Workspaces
Although not a requirement from a Module Federation point of view, we went the monorepo route along with Yarn workspaces to facilitate running all of the separate applications simultaneously.
Yarn workspace / folder containing micro sites called sites
Yarn workspace / folder containing micro sites called sites
"private": true,
"scripts": {
"installDependencies": "yarn workspaces run deps",
"build": "yarn workspaces run build",
"start": "concurrently \"wsrun --parallel start\"",
"clean": "rm -fr node_modules sites/**/node_modules && yarn run clean:dist",
"clean:dist": "rm -fr node_modules sites/**/dist"
},
"workspaces": [
"sites/*"
],
"private": true,
"scripts": {
"installDependencies": "yarn workspaces run deps",
"build": "yarn workspaces run build",
"start": "concurrently \"wsrun --parallel start\"",
"clean": "rm -fr node_modules sites/**/node_modules && yarn run clean:dist",
"clean:dist": "rm -fr node_modules sites/**/dist"
},
"workspaces": [
"sites/*"
],
App shell
The app shell is our main SPA, contains the routes, the Redux store and is the host for all of our remote applications.
Example Shell startup file
Example Shell startup file
import 'team-shell/BaseStyles';
import store from 'team-shell/Store';
const Shell = () => (
<Provider store={store}>
<Router />
</Provider>
);
import 'team-shell/BaseStyles';
import store from 'team-shell/Store';
const Shell = () => (
<Provider store={store}>
<Router />
</Provider>
);
Example routes setup - Router.jsx
Example routes setup - Router.jsx
const Landing = React.lazy(() => import('team-landing/Landing'));
const Checkout = React.lazy(() => import('team-checkout/Checkout'));
const Cart = React.lazy(() => import('team-checkout/Cart'));
const LandingRoute = () => (
<React.Suspense fallback={<div className="fd_error_landing" />}>
<Landing />
</React.Suspense>
);
const CheckoutRoute = () => (
<React.Suspense fallback={<div className="fd_error_checkout" />}>
<Checkout />
</React.Suspense>
);
const ShoppingCart = () => (
<React.Suspense fallback={<div className="fd_error_cart" />}>
<Cart />
</React.Suspense>
);
const Routes = () => {
return (
<Router>
<nav>
<NavLinks />
<div>
<ShoppingCart />
</div>
</nav>
<Switch>
<Route path="/" exact>
<LandingRoute />
</Route>
<Route path="/checkout">
<CheckoutRoute />
</Route>
</Switch>
</Router>
);
};
const Landing = React.lazy(() => import('team-landing/Landing'));
const Checkout = React.lazy(() => import('team-checkout/Checkout'));
const Cart = React.lazy(() => import('team-checkout/Cart'));
const LandingRoute = () => (
<React.Suspense fallback={<div className="fd_error_landing" />}>
<Landing />
</React.Suspense>
);
const CheckoutRoute = () => (
<React.Suspense fallback={<div className="fd_error_checkout" />}>
<Checkout />
</React.Suspense>
);
const ShoppingCart = () => (
<React.Suspense fallback={<div className="fd_error_cart" />}>
<Cart />
</React.Suspense>
);
const Routes = () => {
return (
<Router>
<nav>
<NavLinks />
<div>
<ShoppingCart />
</div>
</nav>
<Switch>
<Route path="/" exact>
<LandingRoute />
</Route>
<Route path="/checkout">
<CheckoutRoute />
</Route>
</Switch>
</Router>
);
};
Store example (using Immer.js)
Store example (using Immer.js)
const reducer = (state = { items: [] }, { type, payload }) =>
produce(state, (draft) => {
switch (type) {
case 'cart/add': {
draft.items.push(payload);
return draft;
}
case 'cart/delete': {
const { id } = payload;
draft.items.splice(id, 1);
return draft;
}
default: {
return draft;
}
}
});
const reducer = (state = { items: [] }, { type, payload }) =>
produce(state, (draft) => {
switch (type) {
case 'cart/add': {
draft.items.push(payload);
return draft;
}
case 'cart/delete': {
const { id } = payload;
draft.items.splice(id, 1);
return draft;
}
default: {
return draft;
}
}
});
App shell federation setup (webpack.config.js)
App shell federation setup (webpack.config.js)
new ModuleFederationPlugin({
name: "shell",
filename: "remoteEntry.js",
remotes: {
"team-shell": "shell@http://localhost:3000/remoteEntry.js",
"team-landing": "landing@http://localhost:3001/remoteEntry.js",
"team-checkout": "checkout@http://localhost:3002/remoteEntry.js",
},
exposes: {
"./Store": "./src/federated/store",
"./BaseStyles": "./src/styles/federated/base.css"
},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
"react-dom": {
singleton: true,
requiredVersion: deps["react-dom"],
},
},
}),
new ModuleFederationPlugin({
name: "shell",
filename: "remoteEntry.js",
remotes: {
"team-shell": "shell@http://localhost:3000/remoteEntry.js",
"team-landing": "landing@http://localhost:3001/remoteEntry.js",
"team-checkout": "checkout@http://localhost:3002/remoteEntry.js",
},
exposes: {
"./Store": "./src/federated/store",
"./BaseStyles": "./src/styles/federated/base.css"
},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
"react-dom": {
singleton: true,
requiredVersion: deps["react-dom"],
},
},
}),
(We'll talk about the shared prop further down...)
(We'll talk about the shared prop further down...)
Recap - Shell
Exposes and consumes the Redux store
- Exposes base styles
- Handles routes
Team Checkout
The checkout team exposes the checkout page route, the buy button and the cart:
Checkout webpack.config
Checkout webpack.config
name: "checkout",
filename: "remoteEntry.js",
remotes: {
"team-shell": "shell@http://localhost:3000/remoteEntry.js",
"team-landing": "landing@http://localhost:3001/remoteEntry.js"
},
exposes: {
"./Checkout": "./src/federated/Checkout",
"./BuyButton": "./src/federated/BuyButton",
"./Cart": "./src/federated/Cart",
},
name: "checkout",
filename: "remoteEntry.js",
remotes: {
"team-shell": "shell@http://localhost:3000/remoteEntry.js",
"team-landing": "landing@http://localhost:3001/remoteEntry.js"
},
exposes: {
"./Checkout": "./src/federated/Checkout",
"./BuyButton": "./src/federated/BuyButton",
"./Cart": "./src/federated/Cart",
},
Buy button
const BuyButton = ({ payload, addToCart, children }) => (
<button onClick={() => addToCart(payload)}>{children}</button>
);
export default connect(null, (dispatch) => ({
addToCart: (payload) => dispatch({ type: 'cart/add', payload })
}))(BuyButton);
const BuyButton = ({ payload, addToCart, children }) => (
<button onClick={() => addToCart(payload)}>{children}</button>
);
export default connect(null, (dispatch) => ({
addToCart: (payload) => dispatch({ type: 'cart/add', payload })
}))(BuyButton);
Checkout route example
Checkout route example
const Checkout = ({products}) => {
return (
(...map products)
)
};
const mapStateToPros = state => ({
items: state.items
})
export default connect(mapStateToPros)(Checkout);
const Checkout = ({products}) => {
return (
(...map products)
)
};
const mapStateToPros = state => ({
items: state.items
})
export default connect(mapStateToPros)(Checkout);
Checkout - Standalone
Besides its setup to share and consume UI, checkout also exposes itself as a standalone application. This setup makes it easy for the team to develop their application and also enables them to create catalogs of the Micro frontends they own and provide to the outside world.
Standalone page using mocked product data exposed from team landing (product team)
Standalone page using mocked product data exposed from team landing (product team)
import { products } from 'team-landing/MockedProducts';
const Standalone = () => (
<>
<h2>Checkout (standalone)</h2>...map products
</>
);
import { products } from 'team-landing/MockedProducts';
const Standalone = () => (
<>
<h2>Checkout (standalone)</h2>...map products
</>
);
Recap - Checkout
- Exposes the checkout route
- Buy button
- Cart
- Standalone
Team Landing
Standalone landing app
Standalone landing app
Webpack.config
Webpack.config
name: "landing",
filename: "remoteEntry.js",
remotes: {
"team-shell": "shell@http://localhost:3000/remoteEntry.js",
"team-landing": "landing@http://localhost:3001/remoteEntry.js",
"team-checkout": "checkout@http://localhost:3002/remoteEntry.js",
},
exposes: {
"./Landing": "./src/federated/Landing",
"./MockedProducts": "./src/federated/mocks/products",
},
name: "landing",
filename: "remoteEntry.js",
remotes: {
"team-shell": "shell@http://localhost:3000/remoteEntry.js",
"team-landing": "landing@http://localhost:3001/remoteEntry.js",
"team-checkout": "checkout@http://localhost:3002/remoteEntry.js",
},
exposes: {
"./Landing": "./src/federated/Landing",
"./MockedProducts": "./src/federated/mocks/products",
},
Example landing product page, uses buy button from team checkout
Example landing product page, uses buy button from team checkout
import BuyButton from "team-checkout/BuyButton"
products.map((product, index) => {
return (
<div className="product" key={...}>
<div>{item.name}</div>
<BuyButton payload={...product}>BUY - ${item.price}</BuyButton>
</div>
)
})
import BuyButton from "team-checkout/BuyButton"
products.map((product, index) => {
return (
<div className="product" key={...}>
<div>{item.name}</div>
<BuyButton payload={...product}>BUY - ${item.price}</BuyButton>
</div>
)
})
Recap - Landing
- Exposes the landing (product) route and mocked product data
- Uses buy button from team Checkout
- Standalone
Dependencies
You might have seen the shared property by now.
You might have seen the shared property by now.
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
"react-dom": {
singleton: true,
requiredVersion: deps["react-dom"],
},
},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
"react-dom": {
singleton: true,
requiredVersion: deps["react-dom"],
},
},
This is telling Webpack - see all of my runtime dependencies specified inside package.json as shared dependencies against others (deps), but treat libraries like React and react-dom (libraries that don't allow multiple instantiations) - as singletons. Ensuring that Webpack only loads these libraries once.
Summary
Module federation gives us the opportunity to have multiple teams output small subsets of a site and consume them at runtime within Single Page Applications, to share UI components without NPM, to consume complete configurations, business logic etc, anything you can run through Webpack is now shareable.
The nature of MF also makes it possible to federate parts of an existing application one feature at a time, offloading responsibilities in a team manner instead of continuously adding complexity to a monolithic app.
Resources
Source code for this post is as always available on my Github
Simplified SSR example at Module Federation Examples
Concepts & inspiration for the examples of this post comes from the only book out there today about Module Federation "The Practical Guide to Module Federation" by Jack Herrington & Zach Jackson (Zach is the creator of Module Federation). It's really well written and full with information that'll surely help you going forward with Module Federation.
/ ND