Published on

Micro-Frontend Part 2: Exposing Remote Components in Sitecore XM Cloud

Authors

In the previous Part 1 we explored the basics of Module Federation as a pattern for implementing Micro-Frontends. In Part 2 we will jump right into exposing a Remote Next.js app's components to be consumed by a Sitecore XM Cloud Next.js app, rendering the Remote apps components in Sitecore.

The Scenario

scenario diagram of single host, Sitecore, consuming remote federated app

The Remote MF Next.js app, mf_catalog, is developed locally on localhost:3001, exposing it's react components via ModuleFederationNextPlugin to the Host Next.js Sitecore app, mf_marketing.

The Sitecore app renders the Remote components through wrapper components, of which are Sitecore JSS components that just pass down the props to the Remote components, with a sprinkle of magic.

Implementing the Scenario

You can follow along with this scenario by checking out the demo code.

The steps below will describe the process and important steps. For the purpose of demo and easier setup/checkout, I kept both apps (mf_catalog & mf_marketing) as a Monorepo setup so you can clone the repo and open each app in VSCode as a workspace.

Step 1: The Catalog Remote App

The Catalog Remote App, mf_catalog, will expose three components:

  • Featured Products CTA
  • Product Listing
  • Favorites Dropdown
    • displayed within the Sitecore Header component
    • shows the capability of sharing a store between Remote and Host apps

First, create the Next.js app via command npx create-next-app@latest.

Install the required packages: npm install @module-federation/nextjs-mf @module-federation/typescript zustand

Copy over the .env file from the Sitecore Next.js app and place it into the root directory. The .env file should contain the standard Sitecore properties plus the following for Part 2 and Part 3 XM Cloud interactions. Part 2 will only use NEXT_PUBLIC_SITE_URL:

SITECORE_SITE_NAME=POPULATE_FROM_SITECORE_CLOUD_DEV_SETTINGS
SITECORE_EDGE_CONTEXT_ID=POPULATE_FROM_SITECORE_CLOUD_DEV_SETTINGS
JSS_EDITING_SECRET=POPULATE_FROM_SITECORE_CLOUD_DEV_SETTINGS
NEXT_PUBLIC_SITE_URL=http://localhost:3001
FETCH_WITH=GraphQL

Create the Remote Components

Below I will show just the Featured Products CTA Remote component since the other components give no extra federated functionality (see the github repo link for all three).

Featured Products (FeaturedProductsByCategoryCTA) will:

  • use the next-localization package to translate dictionary entries from the Sitecore i18n provider
  • use a favorites store built with Zustand so users can favorite a product
  • allow the passing of props and children component(s) from Sitecore
  • use React.ReactNode to support passing in editable properties from Sitecore
// FeaturedProductsByCategoryCTA.tsx
import { withPublicUrl } from '@/lib/url-helper';
import { useFavoritesStore } from '@/store/favorites';
import { Product } from '@/types/product';
import { useI18n } from 'next-localization';
import React, { PropsWithChildren, useState } from 'react';

type FeaturedProductByCategoryCTAProps = PropsWithChildren & {
  ctaCategories: ProductCategory[];

  // passed in from Sitecore wrapper component for editability
  saveIcon?: React.ReactNode;
  plusIcon?: React.ReactNode;
};

type ProductCategory = {
  categoryId: string;
  categoryLabel: string;
};

const featuredProductsByCategory: Product[] = [
  {
    id: 101,
    categoryId: '1',
    name: 'Eyeglasses Product 1',
    brand: 'Brand A',
    priceLineThrough: '$100.00',
    price: '$80.00',
    image: '/catalog/product-5.webp',
  },
  {
    id: 102,
    categoryId: '1',
    name: 'Eyeglasses Product 2',
    brand: 'Brand B',
    priceLineThrough: '$200.00',
    price: '$150.00',
    image: '/catalog/product-2.webp',
  },
  ...
];

const FeaturedProductsByCategoryCTA: React.FC<FeaturedProductByCategoryCTAProps> = (props) => {
  const { ctaCategories, plusIcon, saveIcon, children } = props;
  const { t } = useI18n() || { t: (key: string) => key };
  if (!ctaCategories) {
    return <></>;
  }
  const bottomButtonLabel = t('FeaturedProductsByCategoryCTA.BottomButtonLabel') || 'Shop Now';

  const { addFavorite, removeFavorite, isFavorite } = useFavoritesStore();

  const toggleFavorite = (product: Product) => {
    if (isFavorite(product.id)) {
      removeFavorite(product.id);
    } else {
      addFavorite(product);
    }
  };

  // STATE
  const [selectedCategory, setSelectedCategory] = useState<ProductCategory>(ctaCategories[0]);

  // EVENTS
  const selectProductCategory = (category: ProductCategory) => {
    setSelectedCategory(category);
  };
  return (
    <div className="container mx-auto py-4 outline-4 outline-offset-[-4px] outline-red-500">
      <div className="productCTA__selection">
        <div className="container mx-auto py-4">
          <div className="productCTA__selection">
            <div className="inline-flex space-x-2 rounded-lg bg-gray-200 p-2">
              {ctaCategories.map((category, key) => (
                <button
                  key={category.categoryId}
                  onClick={() => selectProductCategory(category)}
                  className={`px-4 py-2 rounded-lg ${
                    selectedCategory.categoryId === category.categoryId
                      ? 'bg-white text-black'
                      : 'bg-gray-300 text-gray-700'
                  }`}
                >
                  {category.categoryLabel}
                </button>
              ))}
            </div>
          </div>
        </div>
      </div>
      <div className="productCTA__categoryProducts">
        <div className="container mx-auto py-4">
          <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
            {featuredProductsByCategory
              .filter((product) => product.categoryId === selectedCategory.categoryId)
              .map((product) => (
                <div key={product.id} className="bg-white p-4 rounded-md shadow-md">
                  <div className="productCTA__item__imgWrap relative">
                    <img
                      src={withPublicUrl(product.image)}
                      alt={product.name}
                      className="w-full h-auto mb-4"
                    />
                    <div className="productCTA__imgActions absolute right-1 bottom-1">
                      {saveIcon && (
                        <button
                          className={`productCTA__save rounded-full border p-1 hover:border-gray-600 ${
                            isFavorite(product.id)
                              ? 'border-red-500 bg-red-100'
                              : 'border-gray-400 bg-white'
                          }`}
                          onClick={() => toggleFavorite(product)}
                        >
                          {typeof saveIcon === 'string' ? (
                            <img
                              src={saveIcon}
                              alt="Save"
                              className={`w-4 h-4`}
                              style={{
                                filter: isFavorite(product.id)
                                  ? 'invert(27%) sepia(94%) saturate(747%) hue-rotate(340deg) brightness(91%) contrast(88%)'
                                  : 'none',
                              }}
                            />
                          ) : (
                            saveIcon
                          )}
                        </button>
                      )}
                      {plusIcon && (
                        <button className="productCTA__plus rounded-full border border-gray-400 bg-white p-1 hover:border-gray-600 ms-1">
                          {typeof plusIcon === 'string' ? (
                            <img src={plusIcon} alt="Plus" className="w-4 h-4" />
                          ) : (
                            plusIcon
                          )}
                        </button>
                      )}
                    </div>
                  </div>
                  <div className="flex justify-between items-center">
                    <div>
                      <h3 className="text-lg font-semibold">{product.name}</h3>
                      <p className="text-gray-500">{product.brand}</p>
                    </div>
                    <div className="text-right">
                      <p className="text-red-500 line-through">{product.priceLineThrough}</p>
                      <p className="text-green-500 font-bold">{product.price}</p>
                    </div>
                  </div>
                </div>
              ))}
          </div>
        </div>
      </div>
      <div className="productCTA__bottomAction text-center">
        <button className="border border-orange-500 bg-white text-orange-500 font-semibold px-4 py-2 rounded-full hover:bg-orange-500 hover:text-white transition-colors cursor-pointer w-3/7">
          {bottomButtonLabel}
        </button>
      </div>
      {children}
    </div>
  );
};

export default FeaturedProductsByCategoryCTA;

Configure Module Federation

Now we need to expose the remote components for consumption by the Host App, Sitecore.

The module federation config settings for Next.js are placed in a separate file called mf.config.js for organization purposes:

// REMOTE mf_catalog/mf.config.js

// storing URL for deploy support
const MF_MARKETING_APP_URL = process.env.MARKETING_APP_URL || 'http://localhost:3000';

// NextFederationPlugin.OPTIONS
const MF_OPTIONS = (isServer, isTypes) => {
  return {
    name: 'mf_catalog',
    filename: 'static/chunks/remoteEntry.js',
    exposes: {
      './FavoritesDropdown': './src/components/FavoritesDropdown',
      './FeaturedProductsByCategoryCTA': './src/components/FeaturedProductsByCategoryCTA',
      './ProductListing': './src/components/ProductListing',
    },
    // The NextFederationPlugin automatically shares all Next and React core dependencies for us
    shared: {
      // share for i18n
      'next-localization': {
        singleton: true,
        import: undefined,
        version: '0.12.0',
        requiredVersion: '^0.12.0',
      },
    },
    extraOptions: {
      //// SAMPLE extra options
      // automaticAsyncBoundary: true,
      // exposePages: true,
    },
  };
};

module.exports = MF_OPTIONS;

The MF Settings are then referenced in the next.config.js file as MF_OPTIONS under webpack settings:

// REMOTE mf_catalog/next.config.js
const NextFederationPlugin = require('@module-federation/nextjs-mf');
const { FederatedTypesPlugin } = require('@module-federation/typescript');
const MF_OPTIONS = require('./mf.config');

const nextConfig = {
  /* config options here */
  reactStrictMode: true,
  assetPrefix: 'http://localhost:3001',
  i18n: {
    // These are all the locales you want to support in your app.
    // These should generally match (or at least be a subset of) those in Sitecore.
    locales: ['en', 'es-ES'],
    // This is the locale that will be used when visiting a non-locale
    // prefixed path e.g. `/styleguide`.
    defaultLocale: 'en',
  },
  webpack(config, options) {
    const { isServer } = options;
    if (!isServer) {
      // This is a workaround for the issue with module federation and Next.js client-side
      config.resolve.fallback = {
        ...config.resolve.fallback,
        fs: false,
        os: false,
        path: false,
      };
    }
    config.plugins.push(new NextFederationPlugin(MF_OPTIONS(isServer)));
    config.plugins.push(
      new FederatedTypesPlugin({
        federationConfig: MF_OPTIONS(isServer, false),
        typeFetchOptions: {
          downloadRemoteTypesTimeout: 10000,
          maxRetryAttempts: 10,
          retryDelay: 2000,
        },
        //// enable below if hosting type generation on separate webpack server due to multiple host apps
        // typeServeOptions: {
        //   port: 3003,
        //   host: 'localhost',
        // },
      })
    );

    return config;
  },
};

module.exports = nextConfig;

Step 2: The Sitecore Host App

The Sitecore Host App, mf_marketing, contains the Next.js rendering host under mf_marketing/headapps/nextjs-starter.

The Host app will also need the same npm packages installed for Module Federation as Step 1.

Configure Module Federation for the Remote & Remote Type Generation

The Host app will register the remote using the remotes property:

// HOST mf_marketing/headapps/nextjs-starter/mf.config.js
const MF_OPTIONS = (isServer) => {
  return {
    name: 'mf_marketing',
    remotes: {
      mf_catalog: `mf_catalog@${
        process.env.CATALOG_APP_URL || 'http://localhost:3001'
      }/_next/static/${isServer ? 'ssr' : 'chunks'}/remoteEntry.js`,
    },
    filename: 'static/chunks/remoteEntry.js',
    dts: false,
    shared: {
      'next-localization': {
        singleton: true,
        import: undefined,
        version: '0.12.0',
        requiredVersion: '^0.12.0',
      },
    },
    extraOptions: {
      automaticAsyncBoundary: true,
      exposePages: true,
    },
  };
};

module.exports = MF_OPTIONS;

Then in the Next config, we will apply the MF_OPTIONS again under webpack, but also enable types generation (other settings muted):

// HOST mf_marketing/headapps/next.config.js
const jssConfig = require('./src/temp/config');
const NextFederationPlugin = require('@module-federation/nextjs-mf');
const { FederatedTypesPlugin } = require('@module-federation/typescript');
const MF_OPTIONS = require('./mf.config');

/**
 * @type {import('next').NextConfig}
 */
const nextConfig = {
  i18n: {
    // These are all the locales you want to support in your application.
    // These should generally match (or at least be a subset of) those in Sitecore.
    locales: ['en', 'es-ES'],
    // This is the locale that will be used when visiting a non-locale
    // prefixed path e.g. `/styleguide`.
    defaultLocale: jssConfig.defaultLanguage,
  },

  webpack(config, options) {
    const { isServer } = options;
    config.infrastructureLogging = { level: 'log' }; // enables verbose webpack logging
    config.plugins.push(new NextFederationPlugin(MF_OPTIONS(isServer)));
    config.plugins.push(
      new FederatedTypesPlugin({
        federationConfig: MF_OPTIONS(isServer),
      })
    );  // enable MF type generation

    return config;
  },
};

Generating the Remote Types

Before building and running the Sitecore app locally, let's head over to the package.json and make a small update required for MF below.

We add NEXT_PRIVATE_LOCAL_WEBPACK=true so that next will not use its bundled copy of webpack which cannot be used as Module Federation needs access to all of webpack internals.

"scripts": {
    "start:connected": "cross-env NODE_OPTIONS='--inspect' NODE_ENV=development NEXT_PRIVATE_LOCAL_WEBPACK=true npm-run-all --serial bootstrap --parallel next:dev start:watch-components",
}

Now we can start the Remote Catalog app under mf_catalog with npm run dev to expose the Manifest entry file and optionally load the site under http://localhost:3001/catalog:

PS C:\source\MicroFrontend-Sitecore\mf_catalog> npm run dev
> cross-env NEXT_PRIVATE_LOCAL_WEBPACK=true next dev -p 3001
  ▲ Next.js 14.2.28
  - Local:        http://localhost:3001
  - Environments: .env

 ✓ Starting...

[ Module Federation Manifest Plugin ] Manifest Link: http://localhost:3001/_next/static/chunks/mf-manifest.json
[ Module Federation Manifest Plugin ] Manifest Link: http://localhost:3001/_next/mf-manifest.json

Then run the Sitecore mf_marketing app as normal with npm run start:connected. Next, load the site under http://localhost:3000.

Since we have the webpack logging set to verbose and the Sitecore debug set to all (*), I'll show the important Sitecore and MF outputs below:

PS C:\source\MicroFrontend-Sitecore\mf_marketing\headapps\nextjs-starter> npm run start:connected

Registering generate-component-builder plugin component-builder
...
Registering middleware plugin multisite
...
Registering page-props-factory plugin normal-mode
...
Registering JSS component FeaturedProductsByCategoryCTA
...
  ▲ Next.js 14.2.28
  - Local:        http://localhost:3000
  - Environments: .env.local, .env

 ✓ Starting...
...
[FederatedTypesPlugin] Generating types
[FederatedTypesPlugin] Preparing to download types from remotes on startup
[FederatedTypesPlugin] Getting types index for remote 'mf_catalog'
[FederatedTypesPlugin] Downloading types...
[FederatedTypesPlugin] PATCHED NODE MODULES! {
  origin: 'http://localhost:3001/_next/static/chunks',
  typescriptFolderName: '@mf-types',
  file: 'FeaturedProductsByCategoryCTA.d.ts',
  pathUrlString: 'http:\\localhost:3001\\_next\\static\\chunks\\@mf-types\\FeaturedProductsByCategoryCTA.d.ts'
    }
[FederatedTypesPlugin] downloading complete
...
[ Module Federation Manifest Plugin ] Manifest Link: http://localhost:3000/_next/static/chunks/mf-manifest.json
...

Create the Host Wrapper Components

The Host wrapper component shouldn't have to do much but React.Lazy import and render the Remote component. According to the documentation, the use of next/dynamic to import remote modules will cause Hydration Errors.

We could get away with doing an Eager / Sync import as well, but it's recommended to use dynamic imports when possible to avoid large upfront network transfers or requests.

// ./src/components/CatalogWrappers/FeaturedProductsByCategoryCTA.tsx
const MF_FeaturedProductsByCategoryCTA = lazy(
  () => import('mf_catalog/FeaturedProductsByCategoryCTA')
);

const FeaturedProductsByCategoryCTA: React.FC<FeaturedProductsByCategoryProps> = (props) => {
  if (!props.fields?.data?.datasource) {
    return null;
  }
  const { datasource } = props.fields.data;
  return (
    <>
      <Suspense fallback={<CTALoader />}>
        <MF_FeaturedProductsByCategoryCTA
          ctaCategories={datasource.ctaCategories?.targetItems.map((item) => ({
            categoryId: item.categoryId.jsonValue.value as string,
            categoryLabel: item.categoryLabel.jsonValue.value as string,
          }))}
          saveIcon={<Image field={datasource.saveIcon?.jsonValue} className="save-icon" />}
          plusIcon={<Image field={datasource.plusIcon?.jsonValue} className="plus-icon" />}
        >
          <section className="p-4 mt-2 outline-2 outline-offset-[-4px] outline-blue-500">
            <p>
              This content comes from the Sitecore wrapper component that renders the Remote{' '}
              <code>mf_catalog</code> component, passing through Sitecore Datasource content to the
              federated module, such as the save icon as a ReactNode for editability.
            </p>
            {datasource.testTitle?.jsonValue && (
              <p className="test-title">
                <span className="text-blue-500 font-semibold">
                  Datasource field <code>&quot;testTitle&quot;</code> value by language:{' '}
                </span>
                <Text field={datasource.testTitle.jsonValue} />
              </p>
            )}
          </section>
        </MF_FeaturedProductsByCategoryCTA>
      </Suspense>
    </>
  );
};

export default FeaturedProductsByCategoryCTA;

I like to use react-loading-skeleton to create the loading skeleton and prevent CLS for any React.Suspense boundaries that are typical while using React.lazy:

// ./src/components/Loaders/CTALoader.tsx
import React from 'react';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';

const CTALoader: React.FC = () => {
  return (
    <div className="container mx-auto py-4">
      <div className="flex flex-col gap-8">
        <Skeleton
          containerClassName="w-[250px] shadow-lg rounded-md"
          height={50}
          borderRadius={8}
        />
        <Skeleton
          containerClassName="flex justify-center items-center gap-8"
          borderRadius={8}
          count={3}
          height={200}
        />
      </div>
    </div>
  );
};

export default CTALoader;

Step 4: Load The Host App

With MF configured on both ends, Remote types being generated within the Host app, and our Sitecore wrapper component configured to use the Remote component, we can finally load the Homepage and see the results!

scenario diagram of single host, Sitecore, consuming remote federated app

To help visualize where the components come from, the mf_catalog components are outlined in red whereas the mf_marketing (sitecore) components are outlined in blue.

We've successfully exposed Remote catalog components into the Host Sitecore app with a state store that can communicate across boundaries! Clicking on a ❤️ icon from one of the product cards will update the Zustand Favorites store, providing reactive updates to the Favorites dropdown.

Up Next

  • In Part 3 of this series, we will dive deeper into Remote Components, creating a Product Detail component and PDP Service in Remote that uses the Sitecore component-level data fetching in the Host App.

Troubleshooting

If you're still on a certain Windows 10 version, you may run into an error while generating types for the FederatedTypesPlugin that has to do with how paths are resolved differently using node. The fun workaround isn't actually bashing your head against the keyboard, it's patching the node_module package, cool!

  1. npm install patch-package
  2. Add the following to the end of the package.json scripts section:
  "postinstall": "patch-package"  // postinstall is a reserved npm lifecycle script, neat!
  1. Open up the FederatedTypesPlugin.js file under node_modules/@module_federation/typescript/dist and replace this section:
if (filesToCacheBust.length > 0) {
    await Promise.all(filesToCacheBust.filter(Boolean).map((file) => {
        this.logger.log("~~~ I'VE SNUCK INTO THE NODE MODULES ~~~", { origin, typescriptFolderName, file, pathUrlString: path_1.default.join(origin, typescriptFolderName, file) })
        // const url = new URL(path_1.default.join(origin, typescriptFolderName, file)).toString();
        const url = new URL(`${origin}/${typescriptFolderName}${file.indexOf('/') === 0 ? '' : '/'}${file}`).toString();
        const destination = path_1.default.join(this.normalizeOptions.webpackCompilerOptions.context, typescriptFolderName, remote);
        this.logger.log('Downloading types...');
        return (0, download_1.default)({
            url,
            destination,
            filename: file,
        });
    }));
    this.logger.log('downloading complete');
}
  1. Run npm install, which now will sniff out our code change and apply it as a patch under patches/@module-federation+typescript+3.1.3.patch.