Micro Frontends: From CRA to single-spa

March 07, 2021

There are lots of resources in the wild on why you should switch to micro-frontends, why you should keep away or if you decide to go down that path what are your alternatives. In this post, I assume that you want to build micro-frontends using single-spa and you want to learn about how you can configure your existing create-react-app 4 application as a single-spa application and keep CRA still working.

TL;DR To configure your CRA4 application as a single-spa application; install craco-plugin-single-spa-application package, create a craco config following its README and build your app using craco.

Objectives

  1. Set the webpack config output.libraryTarget value as "system".
  2. Make sure the bundle has exactly one entry point. (don't worry async chunks are supported).
  3. Make sure css is placed in the js files and prevent any css file generation.
  4. The module exports single-spa application lifecycle methods.

To achieve these without ejecting, we are going to use craco

The Recipe

1. Install craco by running npm install @craco/craco --save-dev

2. Create an empty craco config as follows

//craco.config.js
module.exports = {
  webpack: {
    plugins: {
        add: []
        remove: [],
    },
    configure: webpackConfig => {
      return webpackConfig
    },
  },
}

3. Add following scripts to package.json to be able to start or build using craco

//package.json
{
  //...
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "craco:build": "craco build", // <-- Add these
    "craco:start": "craco start" // <-- Add these
  }
  //...
}

Now, when you run npm run craco:build or npm run craco:start commands, the app should be working as normal CRA4 app works. We did not do any changes on build configuration yet.

4. Create a new entry point for the app. Let's call it single-spa-index.tsx (or jsx if you are not using typescript) and add the following code.

//src/single-spa-index.tsx
import React from "react"
import ReactDOM from "react-dom"
import singleSpaReact from "single-spa-react"
import { App } from "./App"

const lifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: App,
  errorBoundary(err, info, props) {
    return (
      <div className="h-16 flex items-center justify-between px-6 bg-primary text-white">
        Error
      </div>
    )
  },
})

export const bootstrap = lifecycles.bootstrap
export const mount = lifecycles.mount
export const unmount = lifecycles.unmount

Be aware that you are not hitting srx/index.tsx (or jsx) anymore. So if you were doing any work like importing css files or initializing stuff you need to move them somewhere else, App component probably would be the best place to move these.

5. Make sure the output is in system format by making the following changes in craco.config.js

It is also necessary to add SystemJSPublicPathPlugin so that any assets or async chunks generated by the build will be discoverable by system.js

const path = require("path")
const SystemJSPublicPathPlugin = require("systemjs-webpack-interop/SystemJSPublicPathWebpackPlugin")

module.exports = {
  webpack: {
    plugins: {
      add: [
        new SystemJSPublicPathPlugin({
          systemjsModuleName: `my-single-spa-app`,
        }),
      ],
      remove: [],
    },
    configure: webpackConfig => {
      webpackConfig.entry = path.resolve("src/single-spa-index.tsx") //make sure this points to the new entry file you created in the previous step
      webpackConfig.output.filename = "my-single-spa-app.js"
      webpackConfig.output.libraryTarget = "system"
      return webpackConfig
    },
  },
}

After these changes, when you run npm run craco:build the main entry of the built application will be in build/my-single-spa-app.js and it will be in system format. Now, when you look at build/asset-manifest.json file you should see will see something like this;

{
  "files": {
    "main.css": "/static/css/main.b576dd1d.chunk.css",
    "main.js": "/static/js/main.cfff2238.chunk.js",
    "main.js.map": "/static/js/main.cfff2238.chunk.js.map",
    "runtime-main.js": "/my-single-spa-app.js",
    "runtime-main.js.map": "/my-single-spa-app.js.map",
    "static/js/2.22223850.chunk.js": "/static/js/2.22223850.chunk.js",
    "static/js/2.22223850.chunk.js.map": "/static/js/2.22223850.chunk.js.map",
    "index.html": "/index.html",
    "static/css/main.b576dd1d.chunk.css.map": "/static/css/main.b576dd1d.chunk.css.map",
    "static/js/2.22223850.chunk.js.LICENSE.txt": "/static/js/2.22223850.chunk.js.LICENSE.txt"
  },
  "entrypoints": [
    "my-single-spa-app.js",
    "static/js/2.22223850.chunk.js",
    "static/css/main.b576dd1d.chunk.css",
    "static/js/main.cfff2238.chunk.js"
  ]
}

entrypoints contains more than one entry and wee don't want that because our app will not be able to imported by system.js at all.

Notice that one of the entrypoints is a css file and getting rid of those chunks will require a different approach than what should be done with the js chunks.

Let's start with eliminating the extra js entrypoints.

6. Disable runtime chunk

Add the following line to your craco config's configure method;

delete webpackConfig.optimization.runtimeChunk

a new build will have entrypoints similar to that:

[
  "static/js/1.df40a493.chunk.js",
  "static/css/main.b576dd1d.css",
  "my-single-spa-app.js"
]

What we have now is my-single-spa-app.js which has the code we wrote, the stuff in src folder and the vendor chunk (static/js/1.df40a493.chunk.js).

7. Disable sync chunk splitting

Vendor chunks generated by webpack are sync chunks and we don't want those as they introduce additional entrypoints for our app. So we disable them by adding the following into the craco config.

webpackConfig.optimization.splitChunks = {
  chunks: "async", // split the chunk only if it is an async chunk (allow lazy loading)
}

Now we have a single js entrypoint as intended.

8. Disable css file generation

CRA uses the loader of mini-css-extract-plugin for css bundling and it generates css files. To make sure our style files are bundled in the javascript files, we need to replace this loader with style-loader.

Following code looks for all loader rules in the webpack config and if any of the strings contain mini-css-extract-plugin, they get replaced with path to style-loader

webpackConfig.module.rules[1].oneOf.forEach(x => {
  if (!x.use) return
  if (Array.isArray(x.use)) {
    x.use.forEach(use => {
      if (use.loader && use.loader.includes("mini-css-extract-plugin"))
        use.loader = require.resolve("style-loader/dist/cjs.js")
    })
  }
})

Now, when you look at the build/asset-manifest.json file after getting a new build, you should see only on entry in the entrypoints array and it should be my-single-spa-app.js

9. We don't need an html file

Since we don't need it, we can remove index.html generation by adding HtmlWebpackPlugin string to the craco config's webpack.plugins.remove array.

module.exports = {
  webpack: {
    plugins: {
      add: [
        new SystemJSPublicPathPlugin({
          systemjsModuleName: `my-single-spa-app`,
        }),
      ],
      remove: ["HtmlWebpackPlugin"],
    },
    configure: webpackConfig => {
      ///....
    },
  },
}

10. Enable CORS on devServer

If you wish to run this app locally using the devServer and include it in the root-config project, you need to make sure CORS is enabled since the page url will be different than where the script is served from.

Just set Access-Control-Allow-Origin: * header in devServer section of the craco.config.js

module.exports = {
  //***
  devServer: {
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
}

Final craco.config.js

const path = require("path")
const SystemJSPublicPathPlugin = require("systemjs-webpack-interop/SystemJSPublicPathWebpackPlugin")

module.exports = {
  webpack: {
    plugins: {
      add: [
        new SystemJSPublicPathPlugin({
          systemjsModuleName: `my-single-spa-app`,
        }),
      ],
      remove: ["HtmlWebpackPlugin"],
    },
    configure: webpackConfig => {
      webpackConfig.entry = path.resolve("src/single-spa-index.tsx") //make sure this points to the new entry file you created in the previous step
      webpackConfig.output.filename = "my-single-spa-app.js"
      webpackConfig.output.libraryTarget = "system"

      delete webpackConfig.optimization.runtimeChunk

      webpackConfig.optimization.splitChunks = {
        chunks: "async",
      }

      webpackConfig.module.rules[1].oneOf.forEach(x => {
        if (!x.use) return
        if (Array.isArray(x.use)) {
          x.use.forEach(use => {
            if (use.loader && use.loader.includes("mini-css-extract-plugin"))
              use.loader = require.resolve("style-loader/dist/cjs.js")
          })
        }
      })

      webpackConfig.module.rules.push({ parser: { system: false } })

      return webpackConfig
    },
  },
  devServer: {
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
}

Conclusion

I tried to show you the steps to re-configure webpack on a CRA4 application without ejecting it so the output bundle can be used as a single-spa application.

If you wish to achieve this with less effort just the craco plugin I developed for this purpose (craco-plugin-single-spa-application ). Head over to its github page and follow the instructions on its README. This way you can stay up to date with all the updates I might introduce to ensure CRA4 apps keep working as a single-spa application.

If you have any comments, questions or suggestions feel free to use the comment section below.


Profile picture

Written by Hasan Ayan a fullstack software engineer from Istanbul. Here, he shares stuff he finds interesting and on software development. Mainly about frontend and React.