Migrating CRA to Micro Frontends with Single SPA

Oğuzhan Olguncu   /   May 08, 2021   /    9 min read

typescriptreacttutorialmicrofrontend

Migrating CRA to Micro Frontends with Single SPA

We started to hear the term Micro Frontend a lot because as web apps getting bigger and bigger each day, they also become harder to maintain by teams of developers without breaking each others code. Thats why people came up with term called Micro Frontend where people develop their web apps seperately, maybe using different libraries or frameworks. One of the projects may use React for the navigation section whereas another project may use Vue or Angular for the footer section. In the end you may end up with something below.

Seperate Micro Frontends

In essence, they are pretty similar to micro services. They both have different development processes, unit tests, end-to-end tests and CI/CD pipelines. As every technology comes with a trade-off, let's see it's pros and cons.

Pros#

  • Easier to maintain
  • Easier to test
  • Independent deploy
  • Increases scalability of the teams

Cons#

  • Requires lots of configuration
  • If one of the projects crashes may affect other micro-frontends as well
  • Having multiple projects run on the background for the

Since we made a brief introduction into micro frontends, we can now start migrating from CRA to Single Spa. I'll share a project which uses Rick and Morty api. Project uses React, Typescript and Chakra UI. Tests are also included.

Working Example#

πŸ”—Project's Github address

Single SPA#

The idea behind Single SPA is it lets us build our micro-frontends around a root or container app that encapsulates all. In this root app we can configure routing, shared dependencies, style guides, api and such. We can use as many micro-frontends as we like. And Single SPA has a powerful cli that enables us to do things above without a hussle.

Before we move on to Single SPA, let's first decide how we are going to split our CRA into micro-frontends.

β”œβ”€ src
β”‚  β”œβ”€ App.tsx
β”‚  β”œβ”€ components
β”‚  β”‚  β”œβ”€ CharacterFeatureCard.tsx
β”‚  β”‚  β”œβ”€ CustomError.tsx
β”‚  β”‚  β”œβ”€ CustomSpinner.tsx
β”‚  β”‚  β”œβ”€ EpisodeCardWrapper.tsx
β”‚  β”‚  β”œβ”€ Layout.tsx
β”‚  β”‚  β”œβ”€ LocationCardWrapper.tsx
β”‚  β”‚  └─ Navbar.tsx
β”‚  β”œβ”€ constants
β”‚  β”‚  β”œβ”€ routes.ts
β”‚  β”‚  └─ urls.ts
β”‚  β”œβ”€ hooks
β”‚  β”‚  β”œβ”€ useFetchCharacters.ts
β”‚  β”‚  └─ useInitialData.ts
β”‚  β”œβ”€ index.tsx
β”‚  β”œβ”€ pages
β”‚  β”‚  β”œβ”€ Episodes.tsx
β”‚  β”‚  β”œβ”€ Locations.tsx
β”‚  β”‚  └─ NotFound.tsx
β”‚  β”œβ”€ react-app-env.d.ts
β”‚  β”œβ”€ setupTests.ts
β”‚  └─ __tests__
β”‚     β”œβ”€ CharacterFeatureWrapper.spec.tsx
β”‚     β”œβ”€ Episodes.spec.tsx
β”‚     β”œβ”€ EpisodesCardWrapper.spec.tsx
β”‚     β”œβ”€ Location.spec.tsx
β”‚     β”œβ”€ LocationCardWrapper.spec.tsx
β”‚     └─ Navbar.spec.tsx
β”œβ”€ type.d.ts

Our project has two features, Locations and Episodes. Components or tests either associated with Locations or Episodes. So it's quite easy to see what to separate when we introduced our project to Single SPA. The final structure will resemble something like.

Seperate Micro Frontends-1

Let's get started by creating our root project. Project projects are essential in Single SPA.

mkdir MFProjects
cd MFProjects
npx create-single-spa

Then, pick the followings:

? Directory for new project single-spa-root
? Select type to generate single-spa root config
? Which package manager do you want to use? yarn
? Will this project use Typescript? Yes
? Would you like to use single-spa Layout Engine No
? Organization name (can use letters, numbers, dash or underscore) Tutorial
cd single-spa-root
yarn add npm-run-all

Organization name is quite critical here. If we name other projects differently we may end up with a broken app, so follow the convention.

In root app we register other projects in Tutorial-root-config.ts.

registerApplication({
  name: '@single-spa/welcome',
  app: () => System.import('https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js'),
  activeWhen: ['/'],
});

name is quite important as well it should always start @Organization name/project-name in our case it's @single-spa/welcome.

app lets us specify import path.

activeWhen for routing purposes.

And, we have another important file called index.ejs. If we register new apps into our root we also need to update index.ejs.

<% if (isLocal) { %>
<script type="systemjs-importmap">
  {
    "imports": {
      "@Tutorial/root-config": "//localhost:9000/Tutorial-root-config.js"
    }
  }
</script>
<% } %>

Update your package.json script section as follows.

"scripts": {
    "start": "webpack serve --port 9000 --env isLocal",
    "lint": "eslint src --ext js,ts,tsx",
    "test": "cross-env BABEL_ENV=test jest --passWithNoTests",
    "format": "prettier --write .",
    "check-format": "prettier --check .",
    "build": "webpack --mode=production",
    "episodes": "cd .. && cd single-spa-app-episodes && yarn start --port 9001",
    "locations": "cd .. && cd single-spa-app-locations && yarn start --port 9002",
    "episodes-build": "cd .. && cd single-spa-app-episodes && yarn",
    "locations-build": "cd .. && cd single-spa-app-locations && yarn",
    "start-all": "npm-run-all --parallel start episodes locations",
    "build-all": "npm-run-all --parallel episodes-build locations-build"
}

We will come back to this part when we add Episodes and Locations.

Now, lets add Episodes project.

npx create-single-spa
? Directory for new project single-spa-episodes
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Which package manager do you want to use? yarn
? Will this project use Typescript? Yes
? Organization name (can use letters, numbers, dash or underscore) Tutorial
? Project name (can use letters, numbers, dash or underscore) tutorial-episodes

This time we picked single-spa application / parcel and specificed project name as tutorial-episodes.

Now, let's add Locations project.

npx create-single-spa
? Directory for new project single-spa-locations
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Which package manager do you want to use? yarn
? Will this project use Typescript? Yes
? Organization name (can use letters, numbers, dash or underscore) Tutorial
? Project name (can use letters, numbers, dash or underscore) tutorial-locations

Before we move on we need to configure our Tutorial-root-config.ts and index.ejs. Head over to your root app and change the followings.

Tutorial-root-config.ts#

import { registerApplication, start } from 'single-spa';

registerApplication({
  name: '@Tutorial/tutorial-episodes',
  app: () => System.import('@Tutorial/tutorial-episodes'),
  activeWhen: ['/episodes'],
});

registerApplication({
  name: '@Tutorial/tutorial-locations',
  app: () => System.import('@Tutorial/tutorial-locations'),
  activeWhen: ['/locations'],
});

start({
  urlRerouteOnly: true,
});

location.pathname === '/' ? location.replace('/episodes') : null;

index.ejs#

<script type="systemjs-importmap">
  {
    "imports": {
      "react": "https://cdn.jsdelivr.net/npm/react@16.13.1/umd/react.development.js",
      "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@16.13.1/umd/react-dom.development.js",
      "@Tutorial/root-config": "http://localhost:9000/Tutorial-root-config.js",
      "@Tutorial/tutorial-episodes": "http://localhost:9001/Tutorial-tutorial-episodes.js",
      "@Tutorial/tutorial-locations": "http://localhost:9002/Tutorial-tutorial-locations.js"
    }
  }
</script>

Let's start building Episodes project. First, add dependencies listed below.

cd single-spa-episodes
yarn add react-infinite-scroller react-lazy-load-image-component axios @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^4 react-router-dom @types/react-router-dom @types/react-lazy-load-image-component

Now, we will copy corresponding folders and files to Episodes project. You can copy filesf from: πŸ”—Project's Github address

β”œβ”€ src
β”‚  β”œβ”€ components
β”‚  β”‚  β”œβ”€ CharacterFeatureCard.tsx
β”‚  β”‚  β”œβ”€ CustomError.tsx
β”‚  β”‚  β”œβ”€ CustomSpinner.tsx
β”‚  β”‚  β”œβ”€ EpisodeCardWrapper.tsx
β”‚  β”‚  β”œβ”€ Layout.tsx
β”‚  β”‚  └─ Navbar.tsx
β”‚  β”œβ”€ constants
β”‚  β”‚  β”œβ”€ routes.ts
β”‚  β”‚  └─ urls.ts
β”‚  β”œβ”€ declarations.d.ts
β”‚  β”œβ”€ hooks
β”‚  β”‚  β”œβ”€ useFetchCharacters.ts
β”‚  β”‚  └─ useInitialData.ts
β”‚  β”œβ”€ pages
β”‚  β”‚  β”œβ”€ Episodes.tsx
β”‚  β”‚  └─ NotFound.tsx
β”‚  β”œβ”€ root.component.test.tsx
β”‚  β”œβ”€ root.component.tsx
β”‚  β”œβ”€ Tutorial-tutorial-episodes.tsx
β”‚  └─ __tests__
β”‚     β”œβ”€ CharacterFeatureWrapper.spec.tsx
β”‚     β”œβ”€ Episodes.spec.tsx
β”‚     β”œβ”€ EpisodesCardWrapper.spec.tsx
β”‚     └─ Navbar.spec.tsx
│─ type.d.ts

Notice that we only copied files that associated with Episodes. We have one more step to do.

Episodes > root.component.tsx#

import React from 'react';
import App from './App';

export default function Root(props) {
  return <App />;
}

App.tsx#

import React from 'react';
import { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { ChakraProvider } from '@chakra-ui/react';

import * as ROUTES from './constants/routes';

const Episodes = lazy(() => import('./pages/Episodes'));
const NotFound = lazy(() => import('./pages/NotFound'));

function App() {
  return (
    <ChakraProvider>
      <Router>
        <Suspense fallback={<p>Loading...</p>}>
          <Switch>
            <Route path={ROUTES.EPISODES} component={Episodes} exact />
            <Route component={NotFound} />
          </Switch>
        </Suspense>
      </Router>
    </ChakraProvider>
  );
}

export default App;

We've created new entry point for our Episodes project. Now, let's add Locations project.

cd single-spa-locations
yarn add react-infinite-scroller react-lazy-load-image-component axios @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^4 react-router-dom @types/react-router-dom @types/react-lazy-load-image-component

Now, we will copy corresponding folders and files to Locations project just like we did for Episodes. You can copy filesf from: πŸ”—Project's Github address

β”œβ”€ src
β”‚  β”œβ”€ components
β”‚  β”‚  β”œβ”€ CharacterFeatureCard.tsx
β”‚  β”‚  β”œβ”€ CustomError.tsx
β”‚  β”‚  β”œβ”€ CustomSpinner.tsx
β”‚  β”‚  β”œβ”€ Layout.tsx
β”‚  β”‚  β”œβ”€ LocationCardWrapper.tsx
β”‚  β”‚  └─ Navbar.tsx
β”‚  β”œβ”€ constants
β”‚  β”‚  β”œβ”€ routes.ts
β”‚  β”‚  └─ urls.ts
β”‚  β”œβ”€ declarations.d.ts
β”‚  β”œβ”€ hooks
β”‚  β”‚  β”œβ”€ useFetchCharacters.ts
β”‚  β”‚  └─ useInitialData.ts
β”‚  β”œβ”€ pages
β”‚  β”‚  β”œβ”€ Locations.tsx
β”‚  β”‚  └─ NotFound.tsx
β”‚  β”œβ”€ root.component.test.tsx
β”‚  β”œβ”€ root.component.tsx
β”‚  β”œβ”€ Tutorial-tutorial-locations.tsx
β”‚  └─ __tests__
β”‚     β”œβ”€ CharacterFeatureWrapper.spec.tsx
β”‚     β”œβ”€ Location.spec.tsx
β”‚     β”œβ”€ LocationCardWrapper.spec.tsx
β”‚     └─ Navbar.spec.tsx
β”œβ”€ type.d.ts

Locations > root.component.tsx#

import React from 'react';
import App from './App';

export default function Root(props) {
  return <App />;
}

Locations > App.tsx#

import { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { ChakraProvider } from '@chakra-ui/react';

import * as ROUTES from './constants/routes';
import React from 'react';

const Locations = lazy(() => import('./pages/Locations'));
const NotFound = lazy(() => import('./pages/NotFound'));

function App() {
  return (
    <ChakraProvider>
      <Router>
        <Suspense fallback={<p>Loading...</p>}>
          <Switch>
            <Route path={ROUTES.LOCATIONS} component={Locations} exact />
            <Route component={NotFound} />
          </Switch>
        </Suspense>
      </Router>
    </ChakraProvider>
  );
}

export default App;

Now let's add a header to our root project. Head over to your index.ejs and replace your body as follows.

<body>
  <main>
    <h2 id="header">The Rick and Morty Characters Directory</h2>
  </main>
  <script>
    System.import('@Tutorial/root-config');
  </script>
  <import-map-overrides-full
    show-when-local-storage="devtools"
    dev-libs
  ></import-map-overrides-full>
</body>

Add those styles to center header.

<style>
      #header {
        width: 100%;
        -webkit-align-items: center;
        -webkit-box-align: center;
        -ms-flex-align: center;
        align-items: center;
        text-align: center;
        margin-top: 1.3rem;
        font-size: 2.25rem;
        line-height: 1.2;
        font-size: "-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
      }
</style>

To run all projects at once, we head over to our root directory and run yarn start-all. Now, if we check localhost:9000 we will see Episodes page being served from localhost:9001 and Locations page being served from localhost:9002. They are being conditionally rendered as we switch in our root project.

πŸ”—Finished Project's Github address

Roundup#

As we can see, setting up micro-frontends is little tedious, but gives us freedom to architect each project differently and that's a pretty good thing if we are work alongside with lots of other devs. Every decision every technique comes with a price so choose wisely.

Thanks for reading πŸ₯³πŸ₯³πŸ₯³.

Edit this page