DEV Community

loading...
Cover image for Migrating CRA to Micro Frontends with Single SPA

Migrating CRA to Micro Frontends with Single SPA

ogzhanolguncu profile image Oğuzhan Olguncu Originally published at ogzhanolguncu.com ・7 min read

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. That's why people came up with a term called Micro Frontend where people develop their web apps separately, 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 microservices. 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 its 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 in the background for the

Since we made a brief introduction to 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: ['/'],
});
Enter fullscreen mode Exit fullscreen mode

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>
<% } %>
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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

Now, let's 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Let's start building Episodes project. First, add the 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
Enter fullscreen mode Exit fullscreen mode

Now, we will copy corresponding folders and files to Episodes project. You can copy files 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
Enter fullscreen mode Exit fullscreen mode

Notice that we only copied files 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 />;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

We've created a 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
Enter fullscreen mode Exit fullscreen mode

Now, we will copy corresponding folders and files to the Locations project just like we did for Episodes. You can copy files 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
Enter fullscreen mode Exit fullscreen mode

Locations > root.component.tsx

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

export default function Root(props) {
  return <App />;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Add those styles to center the 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>
Enter fullscreen mode Exit fullscreen mode

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 a little tedious, but gives us the freedom to architected each project differently and that's a pretty good thing if we are work alongside lots of other devs.
Every decision every technique comes with a price so choose wisely.
Thanks for reading πŸ₯³πŸ₯³πŸ₯³.

Discussion (2)

Collapse
thorstenhirsch profile image
Thorsten Hirsch

Impressive! I really like the example application you've been using to explain the concepts of Micro Frontends.

Now I've got a question. If each micro app is a fully fledged SPA then each micro app has a full set of its JS dependencies packaged via webpack that the browser needs to load. So in your example with a root project + episodes project + locations project we end up with 3 big JS packages, right?

=> Can we wrangle the JS packages through a deduplication pipeline or something alike so that we end up with just a single JS package?

Collapse
ogzhanolguncu profile image
Oğuzhan Olguncu Author

Thank you. We can actually make them share the same dependencies we want through Webpack config and, of course, we need to add those dependencies to index.ejs. In the end, all those apps are getting rendered in a container. In theory, If we add common dependencies to the container and delete them from each app that would reduce the final bundle size.

But I guess that alone would not help you reduce the JS bundle of each app. All those Micro Frontends concepts and libraries are fairly young, maybe in the future people will come up with better solutions.
Let me know if you have further questions. Cheers!

Forem Open with the Forem app