Simple Micro Frontend Monorepo with React and Single-SPA
Today we’re going to explain how to develop simple micro frontend monorepo
Why Micro Frontend?
The web is evolving. Applications are becoming more and more complex, and the need for more modular and maintainable code is growing with them.
Micro frontends are small web applications that are broken down into components or functions that work together to provide a larger application.
The benefits of the micro-frontend pattern include:
- Micro-frontend architectures may be simpler, and thus easier to reason about and manage.
- Independent development teams can collaborate on a front-end app more easily.
- They can provide a means for migrating from an “old” app by having a “new” app running side by side with it.
Why Monorepo?
- Easier to manage, since you don’t need to worry about updating every other repo when changing one’s version
- Sharing code is easier, we can add a project as another project’s dependency
- Sharing the same node_modules for dependencies will save you a little space on your computer
Let’s Begin
Before we discuss the step-by-step instructions, let’s get a quick overview of what makes up the demo app. This app is composed of three sub-apps:
- A micro-frontend-container that serves as the main page container and coordinates the mounting and unmounting of the micro-frontend apps
- A micro-frontend-app1 that only shows when active
- A micro-frontend-app2 that only shows when active
Setting up project
First, you need to have lerna installed globally:
yarn global add lerna
Create a new repository:
git init microfrontend-monorepo && cd microfrontend-monorepo
And initialise lerna inside the repo:
lerna init
Once you did it, your folder should look like this:
We are using Yarn and Yarn workspaces. Yarn workspaces will make our life easier by linking projects and allowing them to consume each other living in the same repository. For more info about Yarn workspaces check the docs https://yarnpkg.com/features/workspaces
We need to tell Lerna we’re using Yarn with workspaces, so add these two lines to lerna.json:
{
"packages": [
"packages/*"
],
"version": "0.0.0",
+ "npmClient": "yarn",
+ "useWorkspaces": true
}
You also need to tell package.json the folders we’re gonna take as workspaces by adding the following:
{
"name": "root",
"private": true,
"devDependencies": {
"lerna": "^4.0.0"
},
+ "workspaces": [
+ "packages/*"
+ ]
}
Creating Root Config
To create a microfrontend app for this demo, we’re going to use a command-line interface (CLI) tool called create-single-spa.
Single-SPA — Framework for bringing together multiple JavaScript microfontends in a frontend application
use the command:
npx create-single-spa
And answer it like the following:
Next we’ll add a version to the /micro-frontend-container/package.json:
{
"name": "@vr/root-config",
+ "version": "1.0.0",
"scripts": {
...
Add your organization name to the root’s package.json file and add the following scripts:
{
- "name": "root",
+ "name": "@vr/root",
"private": true,
+ "scripts": {
+ "bootstrap": "lerna bootstrap",
+ "start": "lerna run start --stream"
},
...
Run the bootstrap command to fetch packages and link dependencies. If the node_modules of your projects ever have packages installed, just run this command again in the root directory.
yarn bootstrap
The lerna run
command chooses the command that the packages will receive to run. As the command to run the project in the micro-frontend-container/package.json is "start", we put lerna run start
. Adding --stream
allow us to see the output of our different packages following its names.
yarn start
Great!, you’ll see default ui from single-spa root config. Ok stop running the server and let’s keep going
Creating the Micro-Frontend Apps
Let’s, again, from the root directory, run:
npx create-single-spa
This time our answers will be:
Next add version & modify the start script of the new package.json to include the port:
{
"name": "@vr/app1",
+ "version": "1.0.0",
"scripts": {
- "start": "webpack serve",
+ "start": "webpack serve --port 9001",
...
Now we have to inform our microfrontend-container that it should run this project. For that, we’ll add this line to the packages/micro-frontend-container/src/index.ejs
:
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@vr/root-config": "//localhost:9000/vr-root-config.js",
+ "@vr/app1": "//localhost:9001/vr-app1.js"
}
}
</script>
<% } %>
Next modify in `microfrontend-layout.html` change the application to our apps name
<single-spa-router>
<main>
<route default>
- <application name="@single-spa/welcome"></application>
+ <application name="@vr/app1"></application>
</route>
</main>
</single-spa-router>
Ok, let’s try:
yarn start
if you did everything right, you should be able to see your default page from app1!
Create simple UI with router
We’ll create a folder with the following files:
first we need to install react-router-dom for a specific package => micro-frontend-app1
lerna add react-router-dom --scope=@vr/app1
- /micro-frontend-app1/src/components/Card/index.tsx
import "./card.css";const Card = () => (
<div className="card1">
<h3>Card From App1</h3>
</div>
);export default Card;
- /micro-frontend-app1/src/components/Card/card.css
.card1 {
width: 400px;
background: #39c999;
border-radius: 12px;
margin: 0 12px 24px;
}
- /micro-frontend-app1/src/pages/about/index.tsx
function About() {
return <h2>App 1 - About Page</h2>;
}export default About;
- /micro-frontend-app1/src/pages/home/index.tsx
import { Link } from "react-router-dom";
import logo from "./logo.svg";
import CardApp1 from "../../components/Card";
import "./home.css";function Home() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Micro Frontend Monorepo</p>
<div className="card-wrapper">
<CardApp1 />
</div>
<div className="mb12">
<Link to="/about">Go to App 1 - About</Link>
</div>
</header>
</div>
);
}export default Home;
- /micro-frontend-app1/src/pages/home/home.css
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}.App {
text-align: center;
}.App-logo {
height: 40vmin;
pointer-events: none;
}@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}.App-link {
color: #61dafb;
}.mb12 {
margin-bottom: 12px;
}@keyframes App-logo-spin {
from {
transform: rotate(0deg);
} to {
transform: rotate(360deg);
}
}a {
text-decoration: none;
color: white;
font-size: 18px;
}a:hover {
color: gray;
}.card-wrapper {
display: flex;
}
- /micro-frontend-app1/src/root.component.tsx
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";const HomePage = lazy(() => import("./pages/home"));
const AboutPage = lazy(() => import("./pages/about"));function Root() {
return (
<BrowserRouter>
<Suspense fallback="">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}export default Root;
Ok, let’s try
yarn start
if you try to open http://localhost:9001 you will see:
Cool! Let’s, create another micro-frontend app with the same flow:
npx create-single-spa
- /micro-frontend-container/src/index.ejs
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@vr/root-config": "//localhost:9000/vr-root-config.js",
"@vr/app1": "//localhost:9001/vr-app1.js",
+ "@vr/app2": "//localhost:9001/vr-app2.js"
}
}
</script>
<% } %>
- /micro-frontend-app2/package.json
{
"name": "@vr/app2",
+ "version": "1.0.0",
"scripts": {
- "start": "webpack serve",
+ "start": "webpack serve --port 9002",
...
- /micro-frontend-app2/src/components/Card/index.tsx
import "./card.css";const Card = () => (
<div className="card1">
<h3>Card From App1</h3>
</div>
);export default Card;
- /micro-frontend-app2/src/components/Card/card.css
.card1 {
width: 400px;
background: #39c999;
border-radius: 12px;
margin: 0 12px 24px;
}
- /micro-frontend-app2/src/pages/home/index.tsx
const Home = () => {
return <h2>App 2 - Home Page</h2>;
};export default Home;
- /micro-frontend-app2/src/root.component.tsx
import HomePage from "./pages/home";const App = () => {
return <HomePage />;
};export default App;
add route in `micro-frontend-app1` at home pages
- /micro-frontend-app1/src/pages/home/index.tsx
import { Link } from "react-router-dom";
import logo from "./logo.svg";
import CardApp1 from "../../components/Card";
import "./home.css";function Home() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Micro Frontend Monorepo</p>
<div className="card-wrapper">
<CardApp1 />
</div>
<div className="mb12">
<Link to="/about">Go to App 1 - About</Link>
</div>
+ <div className="mb12">
+ <Link to="/app2">Go to App 2 - Home</Link>
+ </div>
</header>
</div>
);
}export default Home;
- /microfrontend-container/src/microfrontend-layout.html
<single-spa-router>
<main>
+ <route path="app2>
+ <application name="@vr/app1"></application>
+ </route>
<route default>
<application name="@vr/app1"></application>
</route>
</main>
</single-spa-router>
The repo will like this:
Now we need to link dependencies node_modules with run command:
yarn bootstrap
yarn start
Now if we click `Go to App 2- Home` it will navigate to /app2
Looks great right? 😎
Now how to shared component from microfrontend app to another microfrontend app?
We will share card component from micro-frontend-app2
to micro-frontend-app1
export your card component in vr-app2.tsx
- /micro-frontend-app2/src/vr-app2.tsx
...
export const { bootstrap, mount, unmount } = lifecycles;
+ export { * as Card } from "./components/Card/index";
Now before we export the card component, we need to have function to handle async import like this:
- create utils directory and importModule file in micro-frontend-app1
import React, { lazy } from "react";const handleImport = async (appName, moduleName) => {try {
const comp = await System.import(appName);
return comp[moduleName];
} catch {
return () => <React.Fragment />;
}
};export const importModule = (appName, moduleName) =>
lazy(() =>
handleImport(appName, moduleName).then((comp) => ({
default: comp,
}))
);
Now we can import in home page
- /micro-frontend-app1/src/pages/home/index.tsx
import { Link } from "react-router-dom";
import logo from "./logo.svg";
import CardApp1 from "../../components/Card";
import "./home.css";+ import { importModule } from "../../utils/importModule";+ const CardApp2 = importModule("@vr/app2", "Card");function Home() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Micro Frontend Monorepo</p>
<div className="card-wrapper">
<CardApp1 />
+ <CardApp2 />
</div>
<div className="mb12">
<Link to="/about">Go to App 1 - About</Link>
</div>
<div className="mb12">
<Link to="/app2">Go to App 2 - Home</Link>
</div>
</header>
</div>
);
}export default Home;
Ok let’s try
Cool. That looks and works much better! 🥳🥳
Now I will show you this app in the server
when server micro-frontend-app2 died:
Great! this app still ALIVE 🥳🥳🥳🥳🥳🥳🥳🥳
Conclusion
Micro-frontends are the future of frontend web development.
The benefits are massive, including independent deployments, independent areas of ownership, faster build and test times, and the ability to mix and match various frameworks if needed.
Full source on my github