Implementing Internationalization (i18n) with Module Federation in React Micro-Frontends

Demo Reference

Check out the example project list here: React i18n

Overview

In the realm of micro-frontends, ensuring each application remains as autonomous as possible is crucial. However, scenarios arise where inter-application communication is inevitable. A common requirement is synchronizing language preferences across micro-frontends, such as when a user changes the language setting in one application, and it cascades across all integrated micro-frontends. This documentation outlines a strategy for implementing internationalization using the react-i18next library within a micro-frontend architecture facilitated by Module Federation.

Scenario

Consider two applications: App A (the container application) and App B (the remote application). While App B functions independently as a standalone application, it is also designed to be embedded within App A at runtime. Our objective is to seamlessly synchronize language settings between App A and App B, ensuring both applications can manage and display their localized content.

Architecture and Implementation

Extending Bundler Configuration

To accommodate micro-frontend integration, we extend the existing Webpack/Rspack/Rsbuild and Create React App (CRA) configurations using the Module Federation plugin. This extension allows App B to expose various elements (e.g., components, themes, hooks) for consumption by App A or any other integrated applications without requiring codebase ejection.

Setting Up Internationalization

The react-i18next implementation forms the backbone of our translation functionality. Initially, an instance of i18next is configured and imported directly into the main application file (App.js). This instance leverages a context store to manage state, resources (translations), and plugins.

Addressing Translation Override Challenges

A direct implementation approach where each application initializes its i18next instance could lead to resource conflicts, particularly where translated terms are overridden. To avoid this, we establish separate i18next instances for App A and App B, ensuring each application maintains its translation terms independently.

Implementation Steps

Configuring i18next Instances

For both App A and App B, configure separate i18next instances as follows:

// App A
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import enJSON from './translations/en';
import uaJSON from './translations/ua';

// Translation resources
const resources = {
  en: { translation: enJSON },
  ua: { translation: uaJSON },
};

// Initialize i18next instance for App A
const appAInstance = i18n.createInstance();
appAInstance.use(initReactI18next).init({
  resources,
  lng: 'en', // default language
  fallbackLng: 'en',
  interpolation: { escapeValue: false },
  react: { useSuspense: true },
});

export default appAInstance;

// Repeat similar setup for App B with a separate instance

Integrating i18next Providers

Wrap the application components in I18nextProvider, passing the respective i18next instances to ensure translation contexts are correctly applied.

// App A Wrapper
import { I18nextProvider } from 'react-i18next';
import appAInstance from '../i18n';

const AppAI18nWrapper = ({ children }) => (
  <I18nextProvider i18n={appAInstance}>{children}</I18nextProvider>
);

export default AppAI18nWrapper;

// Repeat similar setup for App B

Language Switching Logic

For App B, write a custom hook to facilitate language switching, for example:

import appBInstance from '../i18n';

const useSwitchLanguage = () => {
  return (languageId) => appBInstance.changeLanguage(languageId);
};

export default useSwitchLanguage;

Expose this hook via Module Federation to allow its consumption by App A or other integrated applications.

// Module Federation exposes configuration
exposes: {
  './hooks/useSwitchAppBLanguage': './src/hooks/useSwitchLanguage',
},

In App A, implement a hook that orchestrates language switching across all integrated micro-frontends:

import useSwitchAppBLanguage from 'remoteAppB/hooks/useSwitchAppBLanguage';
import appAInstance from '../i18n';

const useSwitchLanguage = () => {
  const switchAppBLanguageHook = useSwitchAppBLanguage();
  //Application A
  const switchAppALanguage = (languageCode) => appAInstance.changeLanguage(languageCode);
  //Application B
  const switchAppBLanguage = (languageCode) => switchAppBLanguageHook(languageCode);
  //Both Applications
  const switchAllLanguages = (languageCode) => {
    switchAppALanguage(languageCode);
    switchAppBLanguage(languageCode);
  };

  return { switchAppALanguage, switchAppBLanguage, switchAllLanguages };
};

export default useSwitchLanguage;

Language Switching Interface

Implement a user interface component, such as a button, to trigger language changes across all applications:

import { useSwitchLanguage } from 'src/hooks/useSwitchLanguage';

const LanguageSwitcher = () => {
  const { switchAllLanguages } = useSwitchLanguage();
  const handleLanguageSwitch = (lng) => () => switchAllLanguages(lng);

  return <button onClick={handleLanguageSwitch("ua")}>Change language to Ukrainian</button>;
};

export default LanguageSwitcher;

Handling Integrated Environments

To conditionally display language switcher components (e.g., hide the switcher in App B when embedded in App A), leverage a custom hook like useIsRemote.

The useIsRemote hook is designed to determine if the current application (e.g., App B) is running in a standalone mode or is embedded within another application (e.g., App A). This distinction allows us to conditionally render components based on the application's context.

Below we provided an example of a simplified implementation of the useIsRemote hook that checks for a specific condition to determine the application's environment. In the real application this condition could be based on URL parameters, DOM presence checks, or any other trigger that differentiates between being embedded and running standalone:

import { useEffect, useState } from 'react';

/**
 * Determines if the current application is running as a remote (embedded)
 * or as a standalone application.
 *
 * You should adapt the logic based on the specific criteria that apply to your application's
 * architecture, such as checking for specific URL parameters or the presence
 * of a particular DOM element that would only exist when embedded.
 */
const useIsRemote = () => {
  const [isRemote, setIsRemote] = useState(false);

  useEffect(() => {
    // Check for a URL parameter that indicates embedding
    const searchParams = new URLSearchParams(window.location.search);
    setIsRemote(searchParams.has('embedded'));

    // Alternatively, check for a global variable or a specific DOM element
    // setIsRemote(window.parent !== window || document.getElementById('embed-flag') !== null);
  }, []);

  return isRemote;
};

export default useIsRemote;

Using the useIsRemote Hook

With the useIsRemote hook implemented, you can now use it within your components to conditionally render elements based on whether the application is running standalone or is embedded. Here's an example of how it might be used to conditionally display a language switcher component in App B:

import React from 'react';
import useIsRemote from './hooks/useIsRemote';
import LanguageSwitcher from './components/LanguageSwitcher';

const App = () => {
  const isRemote = useIsRemote();

  return (
    <div>
      {/* Only display the LanguageSwitcher if not running as a remote */}
      {!isRemote && <LanguageSwitcher />}
    </div>
  );
};

export default App;

In this example, LanguageSwitcher will only render when App B is not embedded within App A, based on the isRemote state determined by the useIsRemote hook. This approach ensures that components like language switchers are only shown in appropriate contexts, enhancing the user experience and maintaining the independence of micro-frontend applications.