DEV Community

Nik Bogachenkov
Nik Bogachenkov

Posted on • Updated on

Building Micro Frontends with Vite, React, and TypeScript: A Step-by-Step Guide

In the world of modern web development, the demand for scalable and maintainable applications continues to grow. One way to meet these demands is by breaking down large applications into smaller, independent units through the use of micro frontends. By doing so, we can achieve greater flexibility in development and deployment. This also allows teams to work in parallel on different parts of the application, which speeds up the development process.

In this article, we'll explore how to build a micro frontend application using Vite, React, and TypeScript, leveraging Module Federation—a powerful feature that simplifies the creation and integration of micro frontends. Additionally, we'll dive into how to organize the sharing of TypeScript types and application state between different micro frontend applications.

Table of Contents

What are Micro Frontends and Module Federation?

Before diving into the details, let's clarify some key terms — Micro Frontends and Module Federation.

A Micro Frontend is a development approach where an application is split into small, independent modules, each of which can be developed and deployed separately. This allows teams to work on different parts of the application in parallel, speeding up the development process.

In a typical micro frontend architecture, there is a host application that integrates and utilizes data from one or more remote applications. These remote applications can interact both with the host and with each other.

Module Federation is a feature of Webpack 5 that allows modules from other applications to be dynamically loaded at runtime. This simplifies the creation of micro frontends, as it enables applications to share code without having to rebuild the entire project.

About the Application

image

Source code

The application is a list of artists fetched from the Last.fm API. When an artist is selected, a window opens with detailed information about them.

The application's structure will be as follows:

  • Host application: displays a list of artists.
  • Remote application 1: shows artist details.
  • Remote application 2: contains UI components.

I won’t go into detail about creating React components, but I will provide links to the source code for each section.

Types Sharing

Source Code

When working with micro frontends, code is dynamically loaded, but there’s no automatic way to retrieve TypeScript types. Since we are using TypeScript in this project and various parts of the application refer to the same types, these types need to be shared between modules.

There are several approaches to achieve this, but in this article, we will focus on creating an npm package for types.

Step 1: Creating an npm Package

Let’s start by creating a separate directory and installing the necessary dependencies:



mkdir types && cd types
yarn init -y
yarn add @types/react


Enter fullscreen mode Exit fullscreen mode

In package.json, we’ll provide the basic information:



{
  "name": "vmf-app-types",
  "version": "1.0.0",
  "description": "Type declarations for the showcase project",
  "main": "index.js",
  "license": "MIT",
  "types": "index.d.ts",
  "registry": "https://registry.npmjs.org/",
  "dependencies": {
    "@types/react": "^18.3.5"
  }
}


Enter fullscreen mode Exit fullscreen mode

Step 2: Adding Interfaces

Next, create the index.d.ts file where we will define the interfaces for the artists and albums that we will fetch from the Last.fm API:



type ImageSize = "small" | "medium" | "large" | "extralarge" | "mega";

type Images = {
  size: ImageSize;
  "#text": string;
}[]

interface Artist {
  name: string;
  image: Images;
  listeners: number;
  mbid: string;
  url: string;
}

interface MusicEntity {
  url: string;
  image: Images;
  name: string;
  playcount: number;
}


Enter fullscreen mode Exit fullscreen mode

Step 3: Modules for React Components

Since we’ll be sharing React components between applications, we need to declare modules for them (otherwise, it will make your linter angry). Let’s do that now to avoid revisiting this later:



// Last.fm interfaces
...

// ArtistDetails app
declare module "artistDetails/ArtistDetails" {
  import React from "react";

  interface ArtistDetailsProps {
    mbid: string;
    imgUrl: string | null;
  }

  const ArtistDetails: React.FC<ArtistDetailsProps>;
  export default ArtistDetails;
}

// UI app
declare module "ui/components" {
  import React from "react";

  const Wave: React.FC;

  interface ITitleProps extends React.ComponentProps<"h2"> {
    children?: React.ReactNode;
    size?: "xl" | "lg" | "base";
  }

  const Title: React.FC<ITitleProps>;

  export { Wave, Title }
}


Enter fullscreen mode Exit fullscreen mode

After that, publish our package to npm:



npm login
npm publish


Enter fullscreen mode Exit fullscreen mode

Now, we can use this package in each application.

Note #1

For each application using this package, you need to add a reference to it in the vite-env.d.ts file:

/// <reference types="mf-app-types" />

Another approach is to publish your types under the @types organization on npm.

Note #2

In this case, we’ve published the package as public. If you need privacy, you can create a private npm package and publish it, for example, via GitHub Package Registry.

Now, we can proceed to building the application.

Remote Application #1 - UI Components

image

Source Code

Our UI will consist of two components: a loading indicator (Wave) and a title (Title).

First, let’s create the UI application:



yarn create vite ui --template react-ts
cd ui
yarn


Enter fullscreen mode Exit fullscreen mode

Then, install the necessary dependencies:



yarn add clsx tailwind-merge
yarn add -D tailwindcss postcss autoprefixer @originjs/vite-plugin-federation


Enter fullscreen mode Exit fullscreen mode

The components will be located in src/components and exported from index.tsx:



export { default as Title } from './Title';
export { default as Wave } from './Wave';


Enter fullscreen mode Exit fullscreen mode

Now, configure Module Federation in vite.config.ts using vite-plugin-federation:



import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import ModuleFederationPlugin from "@originjs/vite-plugin-federation";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    ModuleFederationPlugin({
      name: "ui",
      filename: "remoteEntry.js",
      exposes: {
        "./components": "./src/components"
      },
      shared: ["react", "react-dom"],
    }),
  ],
  build: {
    target: "esnext",
    minify: false,
    cssCodeSplit: false
  }
})


Enter fullscreen mode Exit fullscreen mode

Let’s go over each property of the plugin:

  1. name - The name of the remote application.
  2. filename - The entry file for the remote application (default is remoteEntry.js). We will later reference this file to load the remote application.
  3. exposes - A list of components we want to expose from this module. In this case, we’re specifying components/index.tsx, which, in turn, exports our Wave and Title components.
  4. shared - The dependencies and libraries we want to share between our applications. For example, if two applications use the same library, it doesn’t make sense to load it multiple times in the browser. Instead, we can define these dependencies and share them across both applications.

    You can also fine-tune module sharing by specifying the library version, path, and other parameters. More information can be found here.

    I recommend being cautious with shared libraries because it can impact tree-shaking. For instance, if you share a large icon library just to use one icon, or a UI library for one component (like a button), this can significantly increase the bundle size, potentially by several megabytes.

After configuring module federation, we can build our UI application and run it on port 3002. Note that the port must be explicitly set, as we will later reference this port when linking to the application.

package.json:



"preview": "vite preview --port 3002 --strictPort"


Enter fullscreen mode Exit fullscreen mode


yarn build
yarn preview


Enter fullscreen mode Exit fullscreen mode

Now it’s time to create the next remote application — the artist details window.

Remote Application #2 - Artist Details

image

Source Code

This application will expose the ArtistDetails component. This component accepts the artist's id and a link to their photo as props, loads data about the artist from the API, and displays their information, including the top 10 albums and tracks.

Just like with the UI app, we’ll start by creating a new application using Vite:



yarn create vite artist-details --template react-ts


Enter fullscreen mode Exit fullscreen mode

Then, install the dependencies:



yarn add axios swr
yarn add -D tailwindcss postcss autoprefixer @originjs/vite-plugin-federation


Enter fullscreen mode Exit fullscreen mode

Let’s configure module federation right away:

vite.config.ts



ModuleFederationPlugin({
  name: "artistDetails",
  filename: "remoteEntry.js",
  exposes: {
    "./ArtistDetails": "./src/components/ArtistDetails"
  },
  remotes: {
    ui: "http://localhost:3002/assets/remoteEntry.js"
  },
  shared: ["react", "react-dom", "swr"],
}),


Enter fullscreen mode Exit fullscreen mode

You’ll notice a new field here — remotes. In this field, we specify the link to our remote UI application (this is where port 3002 comes in handy).

Then, we can use the UI components as if they were from a normal library:



import { Title, Wave } from "ui/components";


Enter fullscreen mode Exit fullscreen mode

Now, let’s add the ArtistDetails component:



import React from "react";
import Info from "@components/Info";
import TopList from "@components/TopList";

interface IArtistDetailsProps {
  mbid: string;
  imgUrl: string;
}

const ArtistDetails: React.FC<IArtistDetailsProps> = ({ mbid, imgUrl }) => {
  return (
    // render artist details
  );
};

export default ArtistDetails;


Enter fullscreen mode Exit fullscreen mode

We will also build this application and run it on port 3001:



vite preview --port 3001 --strictPort


Enter fullscreen mode Exit fullscreen mode

Now, it's time to bring everything together, for which we’ll need the host application.

Host Application - Artist List

image

Source Code

The creation of this application is no different from the previous ones: we still use the Vite CLI:



yarn create vite artist-details --template react-ts


Enter fullscreen mode Exit fullscreen mode

Next, we install the dependencies, but this time we’ll add @nextui and framer-motion:



yarn add axios swr @nextui-org/react framer-motion
yarn add -D tailwindcss postcss autoprefixer @originjs/vite-plugin-federation


Enter fullscreen mode Exit fullscreen mode

Configure Module Federation:



ModuleFederationPlugin({
  name: "artistList",
  remotes: {
    artistDetails: "http://localhost:3001/assets/remoteEntry.js",
    ui: "http://localhost:3002/assets/remoteEntry.js"
  },
  shared: ["react", "react-dom", "axios", "swr"]
})


Enter fullscreen mode Exit fullscreen mode

As you can see, the shared field is no longer necessary here. We simply use our remote applications.

In this application, we will load a list of top artists from Last.fm and display it as cards. When clicking on a card, we save the selected artist's id and image URL:



const [selectedArtistMbid, setSelectedArtistMbid] = useState<string | null>(null);
const [selectedArtistImgUrl, setSelectedArtistImgUrl] = useState<string | null>(null);


Enter fullscreen mode Exit fullscreen mode

Then, we open a modal with the ArtistDetails component, which we previously exposed from the remote artistDetails application:



import React, { useEffect, useState } from "react";
import useSWR from "swr";
import { Spacer } from "@nextui-org/react";
import { LayoutGroup, motion } from "framer-motion";

import { fetcher } from "@utils/fetcher";
import { filterByPhoto } from "@utils/filterByPhoto";

import ArtistCard from "@components/ArtistCard";
import ArtistsLayout from "@components/ArtistsLayout";
import Modal from "@components/Modal";

import { Wave, Title } from "ui/components";
import ArtistDetails from "artistDetails/ArtistDetails";

interface IArtistListProps {
  children?: React.ReactNode;
}

export interface ArtistsResponse {
  artists: {
    artist: Artist[];
  };
}

const ArtistList: React.FC<IArtistListProps> = () => {
  const [selectedArtistMbid, setSelectedArtistMbid] = useState<string | null>(
    null
  );
  const [selectedArtistImgUrl, setSelectedArtistImgUrl] = useState<
    string | null
  >(null);

  const close = () => setSelectedArtistMbid(null);

  const { data, isLoading } = useSWR<ArtistsResponse>(
    "/?method=chart.gettopartists&format=json&limit=11",
    fetcher
  );

  useEffect(() => {
    const closeOnEscapePressed = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        close();
      }
    };
    window.addEventListener("keydown", closeOnEscapePressed);
    return () => window.removeEventListener("keydown", closeOnEscapePressed);
  }, []);

  const onArtistPress = (mbid: string, imgUrl: string) => {
    setSelectedArtistMbid(mbid);
    setSelectedArtistImgUrl(imgUrl);
  };

  if (isLoading) return <Wave />;

  if (!data) return <Title>Sorry, no data found</Title>;

  return (
    <LayoutGroup>
      <Title className="mx-auto" size="xl">
        Last.fm Top Artists
      </Title>
      <Spacer y={6} />
      <ArtistsLayout>
        {data.artists.artist.filter(filterByPhoto).map((a) => (
          <motion.div key={a.mbid} layoutId={a.mbid}>
            <ArtistCard artist={a} onPress={onArtistPress} />
          </motion.div>
        ))}
      </ArtistsLayout>
      {selectedArtistMbid && (
        <Modal layoutId={selectedArtistMbid} onClose={close}>
          <ArtistDetails
            mbid={selectedArtistMbid}
            imgUrl={selectedArtistImgUrl}
          />
        </Modal>
      )}
    </LayoutGroup>
  );
};

export default ArtistList;


Enter fullscreen mode Exit fullscreen mode

Now our artist list can not only display data but also dynamically load information about each artist from a remote application. All of this happens on the fly, without the need to refresh the entire application. Thus, we have created a fully functional application consisting of several independent micro frontends, each of which handles its own task and can be reused in other projects.

Voila! 🎉

State Sharing in Micro Frontends

Sharing state between micro frontends can be challenging, as each micro frontend operates independently. In the previous example, we used props to pass data between micro frontends. However, depending on your application architecture and requirements, other state-sharing methods might be useful. Let's explore several approaches and how they can be applied in the context of module federation and micro frontends.

1. LocalStorage

localStorage can be handy for sharing data between micro frontends if you need to persist state between sessions. Each micro frontend can read and write to localStorage, allowing them to maintain common data.

2. Custom Events

Using CustomEvent to share data between micro frontends allows you to dispatch events with data and subscribe to these events in other micro frontends.

Usage Example:



// Dispatch an event in one micro frontend
const event = new CustomEvent(
  'artistSelected',
  { 
    detail: { 
      mbid: '123',
      imgUrl: 'http://example.com/image.jpg'
    }
  }
);
window.dispatchEvent(event);

// Subscribe to the event in another micro frontend
window.addEventListener('artistSelected', (event: CustomEvent) => {
  const { mbid, imgUrl } = event.detail;
  // handle artist selection...
});


Enter fullscreen mode Exit fullscreen mode

This method enables decoupled communication between micro frontends, but managing a large number of events can make the code more complex.

3. Message Bus

A Message Bus is a messaging system that allows different micro frontends to exchange data. You can use libraries like postal.js or eventbus for this purpose.

Example using postal.js:



// Publish a message in one micro frontend
postal.publish({
  channel: 'artist',
  topic: 'selected',
  data: {
    mbid: '123',
    imgUrl: 'http://example.com/image.jpg' 
  }
});

// Subscribe to the message in another micro frontend
postal.subscribe({
  channel: 'artist',
  topic: 'selected',
  callback: (data) => {  
    const { mbid, imgUrl } = data;  
    // handle artist selection...
  }
});


Enter fullscreen mode Exit fullscreen mode

The Message Bus provides flexibility in managing messages, but it may require additional setup and maintenance.

4. React Context

React Context can be used to pass data within a single application or between micro frontends if they share the same execution context. This is convenient if the micro frontends are embedded in a single application and operate within the same component tree.

Usage Example:



// Create a context
const ArtistContext = React.createContext(null);

// Context provider
export const ArtistProvider: React.FC = ({ children }) => {
  const [artist, setArtist] = React.useState({ mbid: '', imgUrl: '' });

  return (  
    <ArtistContext.Provider value={{ artist, setArtist }}>  
      {children}  
    </ArtistContext.Provider>
  )
}

// Use the context in another component
import ArtistContext from "shared/ArtistContext";
const ArtistDetails: React.FC = () => {
  const { artist } = React.useContext(ArtistContext);
  // render artist details...
}


Enter fullscreen mode Exit fullscreen mode

React Context is effective for sharing state within a single application, but for micro frontends loaded via module federation, it may be less practical.

5. State Managers

Modern state managers like Redux, Zustand, or Recoil provide powerful state management capabilities. They allow centralized state management and can be used to share data between micro frontends.

Example using Zustand:



import create from 'zustand';

// Create the store
export const useArtistStore = create((set) => ({
  artist: {
    mbid: '',
    imgUrl: ''
  },
  setArtist: (artist) => set({ artist })
}));

// Set state in one micro frontend
import { useArtistStore } from "shared/stores";
const setArtist = useStore((state) => state.setArtist);
setArtist({ mbid: '123', imgUrl: 'http://example.com/image.jpg' });

// Read state in another micro frontend
import { useArtistStore } from "shared/stores";
const artist = useStore((state) => state.artist);


Enter fullscreen mode Exit fullscreen mode

Choosing the right method depends on your application architecture and the interaction requirements between micro frontends. It’s important to evaluate each method based on the specific needs of your project.

Conclusion

In conclusion, micro frontends allow teams to break down monolithic applications into smaller, manageable parts, each of which can be independently developed and deployed. By leveraging Vite's fast build tools and React's component-based architecture, along with Webpack's Module Federation, developers can seamlessly integrate and share code across different parts of their application. Whether you're sharing state via props, localStorage, or more advanced patterns like a message bus, micro frontends offer tremendous flexibility. As micro frontends continue to grow in popularity, mastering these techniques will help you build scalable, maintainable, and performant web applications.

I hope this guide has been helpful and inspiring for your own micro frontend projects.

Top comments (0)