Exposing React App as Web Component - Microfrontend

Exposing React App as Web Component - Microfrontend

Software Development
May 7, 2024
Lately, we had a use case from migrate a very old application written on JSP to one of the modern framework. The application was quite complex and large for a single team to migrate at once. So, the application was broken down into different modules and each module was supposed to develop the module independently.
Each team works autonomously. The core part of the application was still JSP. Some modules were written in Vue and some in React. We decided to write our module in React. Creating the React app was the easiest part. Integration was painful, as expected.
We exposed entire React app as web component and integrated into JSP app. In this tutorial, I am going to list out challenges faced and how we solved the integration.
Microfrontend is trending. And why should we be back, right? So, we decided to use Microfrontend architecture too. If you do not know what is Microfrontend architecture, then here is a summary for you:
Microfrontend architecture is a modern approach to building web applications by breaking them down into smaller, self-contained units known as microfrontends. Each microfrontend represents a specific feature, module, or section of the application and can be developed, tested, and deployed independently. This architecture enables teams to work on different parts of the application simultaneously, promoting faster development cycles and easier maintenance. Microfrontends can be implemented using various technologies and frameworks, allowing teams to choose the tools that best fit their needs. By decoupling the frontend into smaller, more manageable pieces, microfrontend architecture offers scalability, flexibility, and improved collaboration among development teams. Want to know more? Just google the term.

Stage 1: Create React Application

We decided to use Vitejs, Tailwdindcss, shadcn and TypeScript for building our app. Wondering why? Well, everyone can give you reasons why, so I am going to dive straight to steps:
Scaffold the app using vitejs:
# scaffold the app npm create vite@latest my-awesome-app # Move to root folder cd my-awesome-app # Install tailwindcss and postcss yarn add -D tailwindcss postcss autoprefixer # Initialize tailwindcss npx tailwindcss init -p
Configure TypeScript (tsconfig.json)
{ "compilerOptions": { // ... "baseUrl": ".", "paths": { "@/*": [ "./src/*" ] } // ... } }
Update vite.config.ts
# (so you can import "path" without error) npm i -D @types/node
and to file vite.config.ts
import path from "path" import react from "@vitejs/plugin-react" import { defineConfig } from "vite" export default defineConfig({ plugins: [react()], resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, })
Then, I added shadcn.
npx shadcn-ui@latest init
And that’s it. I scaffolded several components as required by our application. So, feel free to do that.

Stage 2: Exposing React App as Web Component

As aforementioned, creating the app went as smooth as possible and I guess that would be same for you. Then, we wanted to expose React App as web component.
Actually, we started with module federation. We installed and configured plugin and attempted to share component. Our Host Application is built with JSP and on the top of that, a part of application is built on Vue. The built file was quite big and with module federation, it loads the JS file on every page. We did not want to load JS on every routes. One method that we could think of is to expose our React Application as web component. The script can be hosted on any static server and integrated into Host application.
So, in our React App, main file (index.tsx or main.tsx)
mport React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import reportWebVitals from './reportWebVitals'; import './index.css'; const root = ReactDOM.createRoot( document.getElementById('root') ); root.render( <React.StrictMode> <App /> </React.StrictMode> ); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals();
We converted entire App to web component as:
import { createRoot } from 'react-dom/client'; import { RootComponent } from './Root'; import './globals.css'; class App extends HTMLElement { connectedCallback() { // eslint-disable-next-line @typescript-eslint/no-this-alias const root = this; const mountPoint = document.createElement('div'); // eslint-disable-next-line @typescript-eslint/no-explicit-any const props: any = {}; for (const i of this.getAttributeNames()) { props[i] = this.getAttribute(i); } const ReactRoot = createRoot(mountPoint); const observer = new MutationObserver(function ( mutations ) { // eslint-disable-next-line @typescript-eslint/no-explicit-any mutations.forEach(function (mutation: any) { if (mutation.type === 'attributes') { props[mutation.attributeName] = mutation.target.attributes[ mutation.attributeName ].value; } }); ReactRoot.render(<RootComponent {...props} />); }); observer.observe(root, { attributes: true //configure it to listen to attribute changes }); root.appendChild(mountPoint); console.log('Found Mount Point', mountPoint); ReactRoot.render(<RootComponent {...props} />); } } window.customElements.define('endring-app', App );
This would expose the app as web component and we could use entire app wherever we want using simple HTML tag:
<endring-app name="name"></endring-app>
Next, step was to host the script on static server. We chose to host the script over express server (https://expressjs.com/en/starter/static-files.html).

Stage 3: Hosting on static server

We created a simple server, that would host the built JS file on express server.
const path = require("path"); const express = require("express"); const app = express(); // Serve static assets app.use(express.static(path.join(__dirname, "frontend", "dist"))); app.use(express.static("public")); // Define route to serve index.html for any route requested app.get("*", (req, res) => { console.log(req); res.sendFile(path.resolve(__dirname, "frontend", "dist", "index.html")); }); // Start express server on port 5000 app.listen(5000, () => { console.log("Server started on port 5000"); });
and made sure the js file resides in built folder correctly. Here, is changes we had to make in vite.config.ts.
import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; // https://vitejs.dev/config/ export default defineConfig({ esbuild: { target: 'es2020' }, build: { outDir: 'dist', rollupOptions: { output: { format: 'umd', assetFileNames: '[name].[ext]', entryFileNames: 'endring-app.js', chunkFileNames: 'chunks/[name].js' } } }, plugins: [react()], resolve: { alias: { '@ek': path.join(__dirname, './src'), '@core': path.join(__dirname, './src/core'), '@pages': path.join(__dirname, './src/pages'), '@components': path.join( __dirname, './src/components' ), '@utils': path.join(__dirname, './src/utils'), '@lib': path.join(__dirname, './src/lib') } } });
  1. One of our struggle was to fix path. Make sure, the build file references the JS file correctly.
  1. Second challenge was to make sure we our output script is in “umd” format.
    1. UMD stands for Universal Module Definition. It's a pattern used in JavaScript development to create modules that work across different environments, including CommonJS, AMD, and as global variables in the browser.
      When we encountered an issue with an already defined identifier, using UMD might fixed it because UMD wraps your code in a structure that helps prevent naming conflicts. By encapsulating your code within a function scope, it can avoid polluting the global namespace, which often leads to conflicts with other scripts that might be running on the same page. This encapsulation ensures that your identifiers (variables, functions, etc.) are scoped properly and won't collide with identifiers from other scripts.

      Stage 4: Consuming script in Vue Application

      Due to our complex application, we compiled Vue application into Plain JS and integrated the built file in JSP. The React application was placed inside Vue application. Our hierarchy was:
      JSP APP =⇒ Vue App =⇒ React App
      Vue integration was also challenging for us. It took us some time to figure out most compatible format. For our case, UMD worked fine and assume that would work for most of the cases. Here is my Vue integration code:
      <script setup lang="ts"> import { ref } from 'vue' import { useScriptTag } from '@vueuse/core' import Spinner from '@/components/Spinner/Spinner.vue' const scriptStatus = ref('loading') useScriptTag( `https://endringskalkulator.dev.jee.prd1.prdroot.net/assets/endring-app.js`, () => { scriptStatus.value = 'loaded' }, ) defineProps<{ name: string }>() </script> <template> <div v-if="scriptStatus === 'loading'" class="center-content p-medium spinner-container" > <Spinner /> </div> <div v-if="scriptStatus === 'loaded'"> <endring-app :name="name"></endring-app> </div> <div v-else> <p>Failed to load the script. Please try again later.</p> </div> </template>
      This way, we only loaded our script on a component. That gave us performance benefit and was one of the main goal of this integration.
      We have not fully migrated our application yet. However, with power of Microfrontend architecture we are running fully production app that is built with JSP, Vue and React. I obviously can not share codebase. However, I will try to create minimal viable repository and link it to this article as soon as possible.
      Feel free to get in touch to discuss regarding Micro-frontend application. My experience was, micro-frontend is great. However, integration can be challenging based on your setup. If your application is small, go with module federation.


    2. What is a micro-frontend? A micro-frontend is a development approach where a web application’s front end is divided into smaller, self-contained modules. Each module can be developed, tested, and deployed independently, enabling teams to work on specific features or functions within the application.
    3. What is the use of micro frontends? Micro frontends enhance web development agility and scalability by allowing independent development of application modules. This approach is particularly useful for large and complex web applications that require flexibility and faster iterations.
    4. What is an example of a Microfrontend? An example of a micro-frontend is an e-commerce website where different teams handle product listings, shopping cart, and user profiles as separate modules, all seamlessly integrated into the main application.
    5. Is micro-frontend a framework? No, micro-frontends are not frameworks themselves but rather an architectural pattern for structuring web applications. Various frontend frameworks like React, Angular, and Vue.js can be used to implement micro frontends.
    6. What is the difference between microservices and micro-frontend? Microservices are backend architectural components, whereas micro-frontends are for the front end. Microservices divide the server-side into independent services, while micro-frontends do the same for the client-side, breaking it into modular components.
    7. How do micro frontends work? Micro frontends work by breaking the frontend of an application into smaller and self-contained micro frontends. Each module is responsible for a specific feature or function and can be developed, tested, and deployed independently.