DEV Community

Cover image for Exploring an experimental Micro-Frontend Architecture with Server-Side Rendering using React Server Components
Viktor Lázár
Viktor Lázár

Posted on • Updated on

Exploring an experimental Micro-Frontend Architecture with Server-Side Rendering using React Server Components

Table of Contents

In recent years, the micro-frontend architecture has gained significant traction, enabling teams to independently develop and deploy features within a larger application. This paradigm empowers organizations to scale development efforts by decentralizing codebases and fostering collaboration across teams. Coupled with server-side rendering (SSR), micro-frontends offer enhanced performance and SEO capabilities. In this blog post, we'll explore an experimental architecture that marries micro-frontends with React Server Components (RSC), pushing the boundaries of what's possible in modern web development.

What are Micro-Frontends?

Micro-frontends extend the concept of micro-services to the frontend, where a large application is broken down into smaller, independently deployable pieces. Each micro-frontend can be built and maintained by different teams, possibly using different technologies. This approach allows teams to work in parallel without stepping on each other's toes, thus accelerating the development process and making the codebase more maintainable over time.

However, this flexibility comes with its own set of challenges. Ensuring consistency in user experience, managing shared state, and optimizing performance across micro-frontends are some of the significant hurdles that developers face.

How Does This Stack Up?

When considering alternative approaches like traditional monolithic applications or Single Page Applications (SPAs), the micro-frontend architecture offers superior scalability and team autonomy. Unlike monoliths, which can become unwieldy as they grow, micro-frontends allow teams to iterate on specific features independently. Compared to SPAs, this architecture provides better initial load performance through SSR, which is crucial for applications with SEO requirements or those targeting low-bandwidth environments.

Introduction to React Server Components (RSC)

React Server Components (RSC) is a new feature of React that allows components to be rendered on the server. Unlike traditional SSR, where HTML is generated on the server, React Server Components can return lightweight "descriptions" of UI components, which the client can then render.

This approach has several advantages:

  • Improved Performance: By offloading some rendering logic to the server, the client-side workload is reduced, leading to faster interactions.
  • Code-Splitting at a Component Level: RSC facilitates finer-grained code-splitting, allowing for only the necessary components to be loaded and rendered.
  • Enhanced Developer Experience: The seamless integration of server and client components allows for a smoother development experience.

The Experimental Architecture: Combining Micro-Frontends with RSC

Our experimental architecture attempts to leverage the strengths of both micro-frontends and React Server Components. Here's an overview of the key components:

  • Independent Micro-Frontend Applications: Each micro-frontend is developed as a standalone application. These applications can be written in different frameworks or technologies but are integrated using a common interface or shared protocol.

  • Server-Side Rendering with React Server Components: The server takes responsibility for rendering React Server Components, returning lightweight component descriptions to the client. This reduces the initial load time and provides a smoother user experience.

  • Shared State Management: To ensure consistency across micro-frontends, a shared state management layer is introduced. This layer allows for state synchronization and communication between different micro-frontends, even if they are built using different technologies.

  • Lazy Loading and Code Splitting: RSC enables lazy loading of components at a granular level, ensuring that only the necessary code is sent to the client. This optimizes performance and reduces the time to interactive (TTI).

Challenges and Considerations

While this architecture offers significant benefits, it is not without its challenges:

  • Complexity: The integration of micro-frontends with React Server Components adds a layer of complexity. Teams must be diligent in managing dependencies, ensuring compatibility, and maintaining a consistent user experience across micro-frontends.

  • Performance Overhead: Although server-side rendering improves the initial load time, it can introduce performance overhead on the server. Proper load balancing and caching strategies are crucial to mitigate this issue.

  • SEO and Accessibility: While SSR improves SEO, developers need to ensure that all micro-frontends adhere to best practices for SEO and accessibility, particularly in a mixed technology environment.

  • Shared Dependencies: Managing shared dependencies across micro-frontends can be challenging. Ensuring that different versions of libraries or frameworks do not conflict is crucial to maintaining application stability.

The Future of Micro-Frontends with RSC

This experimental architecture is just one possible way to harness the power of micro-frontends and React Server Components. As these technologies evolve, we can expect to see more refined patterns and best practices emerge. For now, this approach represents an exciting frontier in web development, offering the potential for highly scalable, performant, and maintainable applications.

By combining micro-frontends with React Server Components, we can create applications that are not only modular and scalable but also optimized for performance and user experience. However, careful consideration must be given to the inherent complexity and potential pitfalls of this approach.

To illustrate a potential real-world use case, consider a large e-commerce platform that needs to scale while maintaining high performance. Using the micro-frontend architecture with React Server Components, each part of the application, such as the product catalog, user reviews, and checkout process, could be developed, deployed, and updated independently. This modular approach allows teams to work on different features simultaneously, reduces the risk of errors during deployment, and improves load times through server-side rendering, ultimately enhancing the user experience and streamlining maintenance.

As always, the best architecture is one that fits your specific use case. If you're considering adopting this experimental approach, weigh the pros and cons carefully and consider starting with a proof of concept to validate your ideas.

Introducing @lazarv/react-server with RSC Delegation

@lazarv/react-server is a cutting-edge framework designed to streamline the development of micro-frontends with robust server-side rendering capabilities. One of the standout features of this framework is its support for React Server Components (RSC), enabling developers to efficiently manage server-side logic while maintaining a seamless user experience. However, what truly sets @lazarv/react-server apart is its brand-new feature, RSC Delegation.

RSC Delegation is a powerful mechanism that allows developers to delegate specific React Server Components to different micro-frontends or even entirely separate servers. This feature ensures that complex or resource-intensive components can be processed on dedicated servers, optimizing performance and scalability. For instance, if one micro-frontend in your application requires heavy data processing, you can delegate that component's execution to a specialized server, leaving the rest of your application to run smoothly.

By using RSC Delegation, @lazarv/react-server enables a more modular and flexible architecture, where each micro-frontend can handle its own server-side rendering tasks independently or delegate them as needed. This not only improves the overall performance of your application but also simplifies the development process, as each component can be optimized for its specific environment without impacting the rest of the system. This feature makes @lazarv/react-server an even more powerful tool for building scalable, maintainable web applications.

Check out more about @lazarv/react-server at https://react-server.dev.

Implementing the Experimental Micro-Frontend Architecture with @lazarv/react-server

Now that we've discussed the theoretical aspects and potential benefits of combining micro-frontends with React Server Components, it's time to dive into a practical implementation. For this, we'll be using the @lazarv/react-server framework — a powerful tool designed specifically for server-side rendering with React Server Components.

This section will guide you through setting up and implementing this architecture using @lazarv/react-server.

Installing @lazarv/react-server

First, you'll need to install the @lazarv/react-server package. You can do this via pnpm, npm or even yarn. For this example project, we’ll be utilizing pnpm, a fast and efficient package manager that’s particularly well-suited for managing dependencies in complex, multi-project environments like micro-frontends.

mkdir my-app
cd my-app
pnpm init
pnpm add @lazarv/react-server 
Enter fullscreen mode Exit fullscreen mode

This command will add the @lazarv/react-server package to your project, leveraging pnpm's efficient dependency management system to ensure that your node_modules folder is optimized and free of duplicates.

Creating the Hosting Application

We’ll start by creating a hosting application that will serve as the main entry point for rendering and orchestrating different micro-frontends.

Create a simple React Server Component that will be rendered on the server:

// ./index.jsx
export default function HostingApplication() {
  return (
    <div>
      <h1>Welcome to the Hosting Application</h1>
      <p>This is a simple React Server Component.</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

With everything set up, you can run the hosting application directly using the react-server command:

pnpm react-server ./index.jsx --open
Enter fullscreen mode Exit fullscreen mode

This command will start the development server on http://localhost:3000 and automatically open the hosting application in your default web browser.

Creating a Simple Micro-Frontend (MFE) Application

Next, we’ll create a small micro-frontend application that can be loaded into the hosting application.

// ./remote.jsx
export default function MFE() {
  return (
    <div>
      <h2>This is the Micro-Frontend Component</h2>
      <p>Loaded from the MFE application.</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Start the MFE application server on port 3001 and open the RSC in the browser using:

pnpm react-server ./remote.jsx --port 3001 --open
Enter fullscreen mode Exit fullscreen mode

Using RemoteComponent to integrate the MFE Application into the Hosting Application

Finally, we’ll integrate the MFE into the hosting application by using RemoteComponent from @lazarv/react-server/router to load the MFE dynamically.

Update the Hosting Application to use RemoteComponent

Modify the HostingApplication component to include the RemoteComponent pointing to http://localhost:3001:

// ./index.jsx
import { RemoteComponent } from '@lazarv/react-server/router';

export default function HostingApplication() {
  return (
    <div>
      <h1>Welcome to the Hosting Application</h1>
      <p>This is a simple React Server Component.</p>

      <RemoteComponent src="http://localhost:3001" />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

With this setup, the RemoteComponent will fetch the RSC payload from the MFE application running on http://localhost:3001 and render it within the hosting application using server-side rendering.

With both the hosting and MFE applications running, you can now test how they work together. The hosting application will dynamically load and display components from the MFE application, allowing you to see how they integrate in real-time. This setup lets you check if everything is functioning correctly and ensure that the micro-frontends are interacting as expected.

Set up package.json scripts

Let's add run-p, a utility from npm-run-all that allows you to run multiple npm scripts in parallel. We will also add a script in the package.json file to start both the hosting and MFE applications simultaneously, while only opening the hosting application in the browser.

First, navigate to your project’s root directory and install the npm-run-all package, which includes the run-p command:

pnpm add -D npm-run-all
Enter fullscreen mode Exit fullscreen mode

Next, you'll update the package.json file to include the scripts for starting both applications. Here’s how you can do it:

{
  "name": "my-app",
  "version": "1.0.0",
  "scripts": {
    "dev:host": "react-server ./index.jsx --name host --open",
    "dev:remote": "react-server ./remote.jsx --name remote --port 3001",
    "dev": "run-p dev:host dev:remote"
  },
  "dependencies": {
    "@lazarv/react-server": "0.0.0-experimental-a6c271a-20240825-5a3908ae"
  },
  "devDependencies": {
    "npm-run-all": "^4.1.5"
  }
}
Enter fullscreen mode Exit fullscreen mode

In this setup:

  • dev:host: Starts the development server of the hosting application and opens it in the default browser. The --name host argument prefixes the server logs with "host" for easier identification in the terminal.
  • dev:remote: Starts the development server of the MFE application on port 3001. The --name remote argument prefixes the logs with "remote" for clarity.
  • dev: Uses run-p to run both dev:host and dev:remote in parallel, ensuring both servers start simultaneously but only the hosting application opens in the browser.

Running the applications

To start both the hosting and MFE applications with the appropriate logging prefixes, run:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

This command will start both servers concurrently. The hosting application will be accessible on http://localhost:3000, and the MFE application will be on http://localhost:3001. Only the hosting application will open automatically in the browser, while logs from both applications will be prefixed with "host" and "remote" respectively for clear identification.

Supporting Client Components in a Micro-Frontend Architecture

In a micro-frontend setup, client components play a crucial role in maintaining the balance between server-rendered content and client-side interactivity. With React's new "use client" directive, client components are still rendered on the server side, ensuring that the initial content is available as quickly as possible. Once the content is sent to the browser, these components are hydrated, which enables their interactivity, such as handling user input, managing state, and responding to events.

By incorporating client components into a micro-frontend architecture, you ensure that each micro-frontend not only contributes to the overall performance and SEO of the application but also provides a rich, interactive experience for users. This dual-rendering capability allows developers to choose the right balance of server-side rendering and client-side interactivity, depending on the needs of each component.

We will demonstrate how to integrate client components into a micro-frontend architecture using @lazarv/react-server. Specifically, we’ll create a new micro-frontend application that includes a client-side counter component, showing how it can be seamlessly integrated into an existing hosting application. This example will highlight the flexibility and power of combining server-rendered and client-rendered components within a unified architecture.

Creating a new MFE application with a counter

Next, we'll create a simple counter component that will be the MFE application.

// ./counter.jsx
"use client";

import { useState } from 'react';

export default function CounterComponent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h2>Counter Component</h2>
      <p>Current Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component uses React's useState hook to keep track of the count and provides two buttons to increment and decrement the count.

To differentiate from the previous MFE, we’ll run this new MFE on port 3002.

pnpm react-server ./counter.jsx --port 3002
Enter fullscreen mode Exit fullscreen mode

Add a script to the package.json file to start this new MFE application and also update the dev script to concurrently run the MFE counter application too:

{
  "scripts": {
    "dev:counter": "react-server ./counter.jsx --port 3002 --name counter",
    "dev": "run-p dev:host dev:remote dev:counter"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now you can run the MFE counter application using the following command too:

pnpm dev:counter
Enter fullscreen mode Exit fullscreen mode

Integrate the Counter MFE Application into the Hosting Application

Finally, let's integrate this new MFE into the existing hosting application.

Modify the HostingApplication component to include the new RemoteComponent for the counter MFE:

// ./index.jsx
import { RemoteComponent } from '@lazarv/react-server/router';

export default function HostingApplication() {
  return (
    <div>
      <h1>Welcome to the Hosting Application</h1>
      <p>This is a simple React Server Component.</p>

      <RemoteComponent src="http://localhost:3001" />
      <RemoteComponent src="http://localhost:3002" />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

With this setup, the hosting application will load both the original MFE and the new counter MFE, displaying them on the same page.

Start the hosting application along with the counter MFE application:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

But this does not work yet! We need to add an import map to resolve the client-side issues.

What is an Import Map?

When working with native ES modules in modern browsers, an import map is a powerful tool that helps you control the way your JavaScript modules are resolved and loaded. By default, when you use the import statement in your code, the browser looks for modules based on their paths or URLs. However, this can become cumbersome when dealing with complex dependencies or when you want to use specific versions of a library without changing all your imports.

An import map allows you to define custom mappings for module specifiers, essentially telling the browser where to find specific modules. This is particularly useful when you're working with libraries or frameworks that are hosted on a CDN, or when you want to maintain control over the version of a module being used across your project.

Here's how an import map works:

  • Module Specifier Mapping: With an import map, you can map a module specifier (the string you use in the import statement) to a specific URL. This means you can use simple names like react or lodash in your imports, and the browser will know exactly where to find them based on your map.

  • Version Control: Import maps allow you to lock your dependencies to specific versions without needing to modify each import statement manually. For example, you can ensure that all references to a library like lodash point to the same version, which is crucial for maintaining consistency across your application.

  • Simplified Imports: Instead of dealing with long, complex URLs or paths, you can keep your import statements clean and easy to read. The import map handles the complexity under the hood.

In summary, import maps are a powerful way to manage module resolution in the browser, making it easier to handle dependencies, control versions, and keep your codebase clean and maintainable when using native ES modules.

Import map definition

To ensure that only a single instance of React is loaded in the browser when working with multiple micro-frontends, it's important to set up an import map. This prevents issues related to having multiple React instances, such as conflicts in state management or hooks not working correctly. Here's how you can add an import map to your project.

To properly support an import map and ensure that only a single instance of React is loaded across your micro-frontend architecture, you can configure the framework by using a react-server.config.mjs file. Below is an explanation and a step-by-step guide to implementing this configuration.

In your project, create a react-server.config.mjs file. This file will be used to define how modules are resolved and shared across different parts of your application.

Add the following content to react-server.config.mjs:

export default {
  importMap: {
    imports: {
      react: "https://esm.sh/react@0.0.0-experimental-58af67a8f8-20240628?dev",
      "react/jsx-dev-runtime": "https://esm.sh/react@0.0.0-experimental-58af67a8f8-20240628/jsx-dev-runtime?dev",
      "react-dom": "https://esm.sh/react-dom@0.0.0-experimental-58af67a8f8-20240628?dev",
      "react-dom/client": "https://esm.sh/react-dom@0.0.0-experimental-58af67a8f8-20240628/client?dev",
      "react-server-dom-webpack/client.browser": "https://esm.sh/react-server-dom-webpack@0.0.0-experimental-58af67a8f8-20240628/client.browser?dev",
      "http://localhost:3002/": "/",
    },
  },
  resolve: {
    shared: [
      "react",
      "react/jsx-dev-runtime",
      "react/jsx-runtime",
      "react-dom",
      "react-dom/client",
      "react-server-dom-webpack/client.browser",
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

The import map is a JSON structure that tells the browser where to find specific modules. In this case, we're specifying that React and related packages should be loaded from esm.sh, a popular CDN for ES modules while it can also translate CommonJS packages to native ES modules.

By pointing to esm.sh, all micro-frontends and the hosting application will download and use the same instance of React and related modules directly from the CDN on the client-side. This avoids the common issue of having multiple versions of React in the same application, which can cause hooks to break and state to behave unexpectedly.

The resolve.shared section ensures that specific modules are shared across all components and micro-frontends, preventing multiple instances from being loaded. This guarantees that any part of your application that imports React, ReactDOM, or related libraries will use the same shared instance from esm.sh.

Now with the import map defined, we can retry running the apps by using pnpm dev and it should work now with the counter component working inside the hosting application!

Implementing Server Actions in a Micro-Frontend Architecture

Server actions in a micro-frontend architecture represent a powerful pattern for managing server-side logic in a modular and secure way. By defining server actions within individual micro-frontends, developers can keep the server-side operations close to the components that need them, maintaining a clean separation of concerns and enhancing scalability.

In a typical micro-frontend setup, different parts of the application are developed and deployed independently, each potentially containing its own server-side logic. Server actions enable each micro-frontend to manage its own server-side tasks, such as handling form submissions, interacting with databases, or processing data. These actions are executed entirely on the server, ensuring that sensitive logic is not exposed to the client, which greatly enhances security.

Moreover, by using server actions within micro-frontends, developers can optimize performance by offloading computationally expensive tasks to the server. This reduces the load on the client, resulting in a more responsive user interface. Since server actions are tied to specific components, they also contribute to a modular architecture where each micro-frontend can be developed, tested, and scaled independently of the others.

Overall, server actions in a micro-frontend architecture allow for a robust, scalable, and secure application structure. They empower each micro-frontend to independently handle its own server-side logic, while still contributing to a cohesive and unified user experience across the entire application.

Read more about server actions in the React documentation at https://react.dev/reference/rsc/server-actions.

Using Server Actions with @lazarv/react-server

Now we will implement another MFE application including a form and we will use a server action to handle form submission while staying in the context of the MFE application even when using the hosting application.

Creating a new MFE application with a form and a server action

Next, we'll create an RSC component including a form element and we will handle the form submit action using a server action.

// ./form.jsx
const state = { name: "" };
export default function ServerActionComponent() {
  return (
    <form action={async (formData) => {
        "use server";
        state.name = formData.get("name");
      }}>
      <h2>Welcome, {state.name || "Anonymous"}</h2>
      <input type="text" name="name" defaultValue={state.name} />
      <input type="submit" value="Submit" />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Similar to the previous counter MFE, we’ll run this new MFE on another a new port again, let's use port 3003 for this one.

pnpm react-server ./form.jsx --port 3003
Enter fullscreen mode Exit fullscreen mode

Again, add a script to the package.json file to start this new MFE application and also update the dev script to concurrently run the MFE counter application too:

{
  "scripts": {
    "dev:form": "react-server ./form.jsx --port 3003 --cors --name form",
    "dev": "run-p dev:host dev:remote dev:counter dev:form"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now you can run the MFE application using a server action by running the following command:

pnpm dev:form
Enter fullscreen mode Exit fullscreen mode

Integrate the server action driven MFE into the Hosting Application

Finally, let's integrate this new MFE using a server action into the existing hosting application.

Modify the HostingApplication to include the new RemoteComponent for the form MFE:

// ./index.jsx
import { RemoteComponent } from '@lazarv/react-server/router';

export default function HostingApplication() {
  return (
    <div>
      <h1>Welcome to the Hosting Application</h1>
      <p>This is a simple React Server Component.</p>

      <RemoteComponent src="http://localhost:3001" />
      <RemoteComponent src="http://localhost:3002" />
      <RemoteComponent src="http://localhost:3003" />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

With this setup, the hosting application will load all of our MFE applications displaying them on the same page.

Start the hosting application along with the counter MFE application:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Using a micro-frontend (MFE) application with a server action in the hosting application allows for a seamless and integrated approach to managing server-side logic across different parts of an application. In this setup, each micro-frontend can independently execute server actions, such as processing data or interacting with a database, while being part of a larger, cohesive system. The hosting application orchestrates these micro-frontends, ensuring they work together harmoniously, while the server actions keep sensitive logic secure and optimize performance by handling complex operations on the server. This approach enhances modularity, scalability, and maintainability, allowing the application to grow and evolve efficiently.

Using Static Site Generation in a Micro-Frontend Architecture

To incorporate Static Site Generation (SSG) into your micro-frontend (MFE) architecture using @lazarv/react-server, we can configure one of the micro-frontends to generate static content at build time. This content will then be served as part of the production build. By using the "remote" flag for the root path, we ensure that the MFE's content is statically generated and included in the hosting application.

Here’s how to extend the existing example to include SSG for one of the micro-frontends:

Update the react-server.config.mjs for SSG

To enable SSG, you'll need to update the react-server.config.mjs configuration to specify that the root path of the first MFE should be statically generated with the "remote" flag. This ensures that the content is pre-rendered at build time.

export default {
  importMap: {
    // ...
  },
  resolve: {
    shared: [
      // ...
    ],
  },
  export() {
    return [
      {
        path: "/", // Specify the root path
        remote: true, // Enable the "remote" flag for MFE SSG
      },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

Build the Micro-Frontend and Hosting Applications for production

Since SSG is only applicable in a production environment, you'll need to build your applications for production. Add the necessary build scripts in your package.json:

{
  "scripts": {
    "build:host": "react-server build ./index.jsx --outDir .react-server --no-export",
    "build:remote": "react-server build ./remote.jsx --outDir .react-server-remote",
    "build:counter": "react-server build ./counter.jsx --outDir .react-server-counter --no-export",
    "build:form": "react-server build ./form.jsx --outDir .react-server-form --no-export",
    "build": "run-s build:host build:remote build:counter build:form",
    "start:host": "react-server start --outDir .react-server --port 3000",
    "start:remote": "react-server start --outDir .react-server-remote --port 3001",
    "start:counter": "react-server start --outDir .react-server-counter --port 3002 --cors",
    "start:form": "react-server start --outDir .react-server-form --port 3003 --cors",
    "start": "run-p start:host start:remote start:counter start:form"
  }
}
Enter fullscreen mode Exit fullscreen mode

We instruct each build to use it's own output directory so we don't use the same output directory for any of the MFE applications and the hosting application. Except the "remote" MFE application we also disable SSG as we don't need that for any other MFE application. We also enable CORS for each MFE application which will be called by the hosting application as the hosting application will send network requests to these MFE applications from http://localhost:3000 which is a different hostname than the MFE is listening on and would raise a CORS error in the browser.

Warning: Keep in mind that this is just an example and not the pattern you should follow for building for production. Simulating such a setup is way beyond the scope of this blogpost.

Preparing the import map for production use

We need to update the import map of the project to use production versions of dependencies when running the applications in production mode:

export default {
  importMap: {
    imports: {
      ...(process.env.NODE_ENV !== "production"
        ? {
            react:
              "https://esm.sh/react@0.0.0-experimental-58af67a8f8-20240628?dev",
            "react/jsx-dev-runtime":
              "https://esm.sh/react@0.0.0-experimental-58af67a8f8-20240628/jsx-dev-runtime?dev",
            "react-dom":
              "https://esm.sh/react-dom@0.0.0-experimental-58af67a8f8-20240628?dev",
            "react-dom/client":
              "https://esm.sh/react-dom@0.0.0-experimental-58af67a8f8-20240628/client?dev",
            "react-server-dom-webpack/client.browser":
              "https://esm.sh/react-server-dom-webpack@0.0.0-experimental-58af67a8f8-20240628/client.browser?dev",
            "http://localhost:3001/": "/",
            "http://localhost:3003/": "/",
          }
        : {
            react:
              "https://esm.sh/react@0.0.0-experimental-58af67a8f8-20240628",
            "react/jsx-runtime":
              "https://esm.sh/react@0.0.0-experimental-58af67a8f8-20240628/jsx-runtime",
            "react-dom":
              "https://esm.sh/react-dom@0.0.0-experimental-58af67a8f8-20240628",
            "react-dom/client":
              "https://esm.sh/react-dom@0.0.0-experimental-58af67a8f8-20240628/client",
            "react-server-dom-webpack/client.browser":
              "https://esm.sh/react-server-dom-webpack@0.0.0-experimental-58af67a8f8-20240628/client.browser",
          }),
    },
  },
  resolve: {
    shared: [
      "react",
      "react/jsx-dev-runtime",
      "react/jsx-runtime",
      "react-dom",
      "react-dom/client",
      "react-server-dom-webpack/client.browser",
    ],
  },
  export() {
    return [
      {
        path: "/",
        remote: true,
      },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

When we detect that NODE_ENV not equals "production" we will still use the development versions of the required dependencies. But when we use production mode, we need to use production builds of React and related packages too, so we need to remove the ?dev query param from the esm.sh URL of dependencies.

Execute the production build

pnpm build
Enter fullscreen mode Exit fullscreen mode

This command will build all applications for production and pre-render the content for the root path of the first MFE and include it in the production build.

Start the production server

After building, you can start the applications in production mode:

pnpm start
Enter fullscreen mode Exit fullscreen mode

This command will serve the statically generated content along with the dynamic content, providing an optimized and pre-rendered user experience.

By integrating Static Site Generation (SSG) into your micro-frontend architecture, you ensure that key parts of your application, such as the first MFE, are pre-rendered and optimized for performance. The use of the "remote" flag in the react-server.config.mjs ensures that these components are statically generated during the production build process. This approach not only improves load times but also enhances SEO and provides a better overall user experience by delivering fully rendered content as soon as possible.

Expanding Static Site Generation Beyond Micro-Frontends

Incorporating Static Site Generation (SSG) into your micro-frontend architecture significantly enhances performance by pre-rendering key parts of your application. While we focused on enabling SSG for a specific micro-frontend (MFE) in this example, it's important to note that SSG isn't limited to just the micro-frontend applications. You can also apply SSG to specific routes within the hosting application itself. By selectively pre-rendering routes in the hosting application, you can optimize the delivery of content that remains relatively static, improving load times and ensuring that your users receive fully rendered pages immediately upon request. This flexibility allows you to tailor your application’s performance and scalability precisely to the needs of your project, combining the benefits of dynamic rendering where needed with the speed of pre-rendered static content.

Server Islands: loading MFE content using ReactServerComponent

To demonstrate how you can load content from micro-frontend (MFE) applications in the hosting application using the ReactServerComponent with url and defer props (similar to Astro’s server islands), we’ll set up the architecture so that MFE content is loaded only on the client side, ensuring that it doesn't affect the initial server-side rendering.

This approach allows you to load micro-frontend content dynamically on the client side, deferring the load until the user interacts with the page or until after the initial server-side render. This method is useful for improving perceived performance and user experience by focusing on essential content first.

Create a new MFE application with Suspense

Let's create another MFE application, but this time let's use Suspense! By using Suspense the MFE application can use a streaming response, providing an initial loading content and then transform the initial MFE content to the content streamed to the client.

// ./streaming.jsx
import { Suspense } from 'react';

async function AsyncComponent() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return (
    <p>
      This is a remote component that is loaded using Suspense. -{" "}
      <b>{new Date().toISOString()}</b>
    </p>
  );
}

export default function Streaming() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <AsyncComponent />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

As usual by now, let's run this MFE using the following command:

pnpm react-server ./streaming.jsx --port 3004 --open
Enter fullscreen mode Exit fullscreen mode

Extend package.json scripts

Add a script to the package.json file to start this new streaming MFE application and also update the dev script to concurrently run the streaming MFE application too:

{
  "scripts": {
    "dev:streaming": "react-server ./streaming.jsx --port 3004 --name streaming",
    "dev": "run-p dev:host dev:remote dev:counter dev:form dev:streaming",
    "build:streaming": "react-server build ./streaming.jsx --outDir .react-server-streaming --no-export",
    "start:streaming": "react-server start --outDir .react-server-streaming --port 3004 --cors",
    "start": "run-p start:host start:remote start:counter start:form start:streaming"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now you can run the MFE application using Suspense by running the following command:

pnpm dev:streaming
Enter fullscreen mode Exit fullscreen mode

Add the streaming MFE Application to the Hosting Application

Modify the HostingApplication to include the new ReactServerComponent for the streaming MFE:

// ./index.jsx
import { RemoteComponent } from '@lazarv/react-server/router';
import { ReactServerComponent } from '@lazarv/react-server/navigation';

export default function HostingApplication() {
  return (
    <div>
      <h1>Welcome to the Hosting Application</h1>
      <p>This is a simple React Server Component.</p>

      <RemoteComponent src="http://localhost:3001" />
      <RemoteComponent src="http://localhost:3002" />
      <RemoteComponent src="http://localhost:3003" />
      <ReactServerComponent url="http://localhost:3004" defer />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

With this setup we keep all of our previous MFE applications rendered using server-side rendering in the hosting application using RemoteComponent while rendering the streaming MFE only on the client by using the ReactServerComponent. As soon as the component renders on the client it starts fetching the streaming content from the streaming MFE we just created.

@lazarv/react-server uses the same ReactServerComponent for hydrating your app on the client side. Only the behavior of the component differs in this case and that the component will fetch it's content from an external source by specifying the url prop and that external source in this case is the streaming MFE.

Note: You can extend this by wrapping the ReactServerComponent into a component using a MutationObserver to render the ReactServerComponent only when it's container becomes visible on the page.

Now start the hosting application along with the streaming MFE application:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

By using ReactServerComponent with the url and defer props in the hosting application, you can dynamically load and render content from micro-frontends on the client side, similar to Astro's server islands. This approach allows you to optimize your application's performance by prioritizing critical content during the initial server-side render, while still leveraging the power of micro-frontends to deliver additional, modular functionality as needed.

Shared State Management in a Micro-Frontend Architecture

Managing shared state across multiple micro-frontends is one of the more challenging aspects of a micro-frontend architecture. When building modular applications with independent micro-frontends, ensuring consistent and efficient state management becomes critical. In this section, we’ll explore both backend state management and client-side cross-app state management strategies.

Backend State Management

Backend state management involves centralizing the application's state on the server. This approach ensures that all micro-frontends access a consistent state, regardless of which part of the application is being used. By keeping the state on the server, you reduce the risk of state divergence and improve synchronization across different parts of your application.

  1. Server-Side State with APIs: In a typical setup, the server maintains the state and exposes it through RESTful or GraphQL APIs. Each micro-frontend can query the server to fetch the current state and update it as needed. For example, if one micro-frontend updates a user profile, that change is immediately reflected when another micro-frontend queries the user data.

  2. Server Actions for State Management: Using server actions, as discussed earlier, is another powerful way to manage state. Server actions allow micro-frontends to interact with server-side logic directly. For instance, you might have a server action that updates a shared counter or user session, ensuring that all parts of your application stay in sync.

  3. State Persistence: Persisting state on the backend can be handled through databases or in-memory data stores like Redis. These data stores allow you to maintain a global state that can be accessed and modified by any micro-frontend, providing a single source of truth for the application.

Client-Side Cross-App State Management

While backend state management ensures consistency, there are scenarios where sharing state directly on the client side is more efficient, especially for real-time interactions or when minimizing server calls is crucial.

  1. Shared State Libraries: Libraries like Redux, Jotai or Zustand can be used to manage client-side state that needs to be shared across multiple micro-frontends. By configuring a shared store, different micro-frontends can read from and write to the same state, ensuring that all components reflect the current state of the application.

For example, you might have a shared Redux store that holds authentication information, and each micro-frontend can access this store to determine whether a user is logged in and what permissions they have.

  1. Custom Event Systems: Implementing a custom event bus is another way to share state across micro-frontends on the client side. This approach involves creating a global event system where micro-frontends can emit and listen for events, allowing them to communicate and synchronize state changes without directly depending on each other.

For instance, when one micro-frontend updates a cart item, it emits an event that other micro-frontends listen for, allowing them to update the cart UI accordingly.

  1. Web Storage: Web storage solutions like localStorage, sessionStorage or IndexedDB can be used for simpler cross-app state sharing. Micro-frontends can write to and read from these storage mechanisms, ensuring that state persists across page reloads or navigations.

This approach works well for non-sensitive data that doesn’t need to be immediately synchronized across micro-frontends but still needs to be accessible globally, like user preferences or temporary filters.

Combining Backend and Client-Side State Management

In practice, a robust micro-frontend architecture often uses a combination of backend and client-side state management. For critical state that requires consistency across all parts of the application, such as user sessions or permissions, backend state management is essential. However, for performance-sensitive operations or real-time interactions, client-side state management offers flexibility and speed.

By carefully choosing where and how state is managed, you can ensure that your micro-frontends are both independent and synchronized, providing a seamless user experience across your application. This balance allows for the efficient development and deployment of individual micro-frontends while maintaining the integrity of the overall application state.

Leveraging Native ES Modules with @lazarv/react-server

The fact that @lazarv/react-server natively supports ES modules through Vite and Rollup is a significant advantage for developers building micro-frontends. Vite’s use of native ES modules during development allows for faster builds and more efficient hot module replacement, making the development process smoother and more responsive. When it comes time to build for production, Rollup optimizes these ES modules, ensuring that your application is as performant as possible.

How Native ES Modules Simplify Micro-Frontend Architecture

  1. Streamlined Development with Vite: Vite’s use of native ES modules means that during development, each micro-frontend can be built and tested independently with minimal configuration. The development server leverages the browser's native support for ES modules, loading dependencies directly as needed. This leads to faster development cycles, as changes are reflected almost instantly without the need for complex bundling steps.

  2. Optimized Production Builds with Rollup: Rollup takes the ES module-based development environment created by Vite and produces optimized bundles for production. These bundles are smaller, more efficient, and easier to manage, which is especially important in a micro-frontend architecture where multiple independently developed frontends need to come together seamlessly.

  3. Simplified Dependency Management: Since @lazarv/react-server uses native ES modules, managing dependencies across different micro-frontends becomes much simpler. Each micro-frontend can specify and import only the dependencies it needs without worrying about global conflicts or redundant code. This also enables you to take advantage of tree-shaking, where unused code is automatically removed during the build process, further optimizing your application.

  4. Enhanced Modularity and Flexibility: With native ES modules, the architecture of your micro-frontends becomes more modular. Each micro-frontend is essentially a self-contained unit that can be developed, tested, and deployed independently. This modularity is enhanced by the fact that Vite and Rollup are designed to work seamlessly with ES modules, allowing for easy integration of new features and components without disrupting the overall application.

  5. Faster Builds and Deployments: The combination of Vite and Rollup enables quicker build times and more efficient deployments. Vite's instant server start and hot module replacement make development faster, while Rollup's tree-shaking and code-splitting capabilities ensure that the final production bundles are as lean as possible. This is particularly beneficial in a micro-frontend environment, where minimizing build and deployment times is crucial for maintaining agility.

The built-in use of native ES modules in @lazarv/react-server, facilitated by Vite and Rollup, significantly enhances the development and deployment process for micro-frontends. This approach not only simplifies dependency management and module bundling but also ensures that each micro-frontend can operate independently while still contributing to a cohesive and performant overall application. By leveraging these tools, @lazarv/react-server offers a streamlined, modular, and efficient architecture that is well-suited to the demands of modern web development.

Final thoughts

Working with @lazarv/react-server to build a micro-frontend architecture has been an exciting journey, showcasing the framework's potential to revolutionize how we approach modular web development. This framework offers a unique blend of server-side rendering, dynamic content loading, and modular front-end development, making it a powerful tool for creating scalable and maintainable applications.

However, it's important to acknowledge that @lazarv/react-server is still in an experimental phase. While the current capabilities are impressive, there are numerous opportunities for refinement and enhancement. For instance, simplifying the API and improving tooling support could make the framework even more accessible to developers. Additionally, as more developers start using it, we can expect the community to drive innovations that further optimize performance and streamline the development process.

One of the most exciting aspects of @lazarv/react-server is its extendability. The architecture is designed to adapt and grow alongside your application’s needs. Whether you’re looking to incorporate advanced caching strategies, integrate with modern deployment pipelines, or explore new rendering techniques, the framework is flexible enough to accommodate these ambitions.

In summary, while @lazarv/react-server is a powerful tool with a promising future, it’s also a work in progress. As the framework evolves, it will undoubtedly continue to push the boundaries of what’s possible in web development, offering new ways to build fast, scalable, and maintainable applications. The journey with @lazarv/react-server is just beginning, and there’s a lot to look forward to as it matures and expands its capabilities.

Top comments (5)

Collapse
 
charidimos_tzedakis_95959 profile image
Charidimos Tzedakis

Something I noticed while running the examples:

In the Streaming example, a parent is needed outside the Suspense for it to work:
So this component:

export default function Streaming() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <AsyncComponent />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

Will need to become:

export default function Streaming() {
  return (
    <div>
      <Suspense fallback={<p>Loading...</p>}>
        <AsyncComponent />
      </Suspense>
   </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
charidimos_tzedakis_95959 profile image
Charidimos Tzedakis

Hello there and congratz for the article!

I tried the example with the host and mfe applications, but it seems that I cannot get the mfe app to render in the host application - get the following error:

Image description

The apps run correctly as standalone in port 3000 and 3001 in the browser though.
I am missing something? Is there a way to see logs or debug the failed fetch request?

Thank you in advance,
Charidimos

Collapse
 
charidimos_tzedakis_95959 profile image
Charidimos Tzedakis • Edited

Probably it is a local issue regarding the localhost resolution - i used the --host flag on the MFE and then used the IP address provied instead of localhost:3001 - then the request worked.

Also i managed to log the error in the console by putting a console log in the RemoteComponent.jsx - it would be helpful to have a way to log it

Image description

Update: no need to use the IP address provided, just with the flag --host it works (using localhost:3001)

Collapse
 
lazarv profile image
Viktor Lázár

Hi @charidimos_tzedakis_95959 !

Sorry, strange but I don't see any notification from dev.to about your comments. Next time in case of an error with @lazarv/react-server please create an issue at github.com/lazarv/react-server/issues and I will get better notifications from GitHub about it.

I just added an onError prop to RemoteComponent so it's easier to get the issue without manually modifying the component.

You can also use an ErrorBoundary component to get the error information on the client. This should work across the MFE boundaries, but I'll check it later.

You should not need to use the --host argument, but if your network setup interferes and MFEs are not available on localhost then it's the default workaround.

Thread Thread
 
charidimos_tzedakis_95959 profile image
Charidimos Tzedakis

Hello Victor! Thank you very much for the reply! - i will try out the ErrorBoundary - also i will try to figure out how to avoid the --host in my env.