Shared Packages in MFE Monorepo

Shared Packages in a Micro Frontend Monorepo

In a Micro Frontend monorepo, shared packages are the glue that keeps your independent MFEs consistent. Without them, every micro frontend duplicates its own API layer, its own Redux store, and its own UI components — leading to inconsistent behavior, duplicated bugs, and wasted bundle size. Shared packages in a micro frontend architecture solve this by centralizing common code into reusable internal libraries that all MFEs consume.

In this article, you'll learn how to create three shared packages from a real production monorepo with 11+ React MFEs: a shared API layer with axios interceptors, a shared Redux store with authentication state, and shared UI components. You'll also see the Webpack alias and Module Federation config that makes it all work.

The Problem — Why You Need Shared Packages

Imagine you have 11 micro frontends in your monorepo. Each MFE needs to:

  1. Make authenticated API calls — attach a JWT token, handle 401 errors, refresh expired tokens
  2. Read user state — check if the user is logged in, get their permissions
  3. Show consistent UI — use the same toast notifications, buttons, and card components

Without shared packages, every MFE implements this independently. Here's what goes wrong:

ProblemImpact
Duplicated axios configToken refresh logic differs between MFEs — some refresh, others log out
Separate Redux storesAuth state is out of sync — one MFE thinks the user is logged in, another doesn't
Copy-pasted componentsA design change requires updating 11 MFEs instead of 1 package
Inconsistent error handlingSome MFEs handle 401 correctly, others silently fail

Shared packages eliminate all of these problems. You write the code once in packages/, and every MFE imports it.

Architecture Overview — Three Shared Packages

Shared packages architecture in a Micro Frontend monorepo showing API layer, Redux store, and UI components consumed by multiple MFEs

Here's the folder structure of the shared packages inside the monorepo:

packages/ — Shared Package Structure
packages/core/api/
├── index.js                  # Axios instance + interceptors (base layer)
├── Registration.js           # Onboarding / registration endpoints
├── RegistrationAuth.js       # Auth endpoints (OTP, login, logout)
├── assetApis.js              # File upload / asset URL resolution
├── Inventory-Apis/
│   └── seller-inventory.js   # 18+ inventory CRUD functions
├── Products-Apis/
│   └── BulkUpload.js         # Bulk product upload + status tracking
├── Order-Apis/
│   └── seller-order.js       # Package listing, labels, tracking
├── Pricing-Apis/
│   └── seller-pricing.js     # NBT pricing, bulk import/export
├── Reviews-Apis/
│   └── seller-reviews.js     # Review management
├── Support-Apis/
│   └── seller-support.js     # Support ticket management
└── Settings/
    └── seller-settings.js    # Account settings

Each shared package has its own package.json, a clear entry point, and is consumed by MFEs via npm workspaces (opens in a new tab) and Webpack resolve aliases.

Let's build each one.

Package 1 — Shared API Layer (@myapp/api)

The shared API package is the most critical shared package. It provides a single axios (opens in a new tab) instance with request interceptors (for attaching JWT tokens) and response interceptors (for handling 401 errors and token refresh). Every MFE imports this instead of creating its own axios config.

Shared API layer architecture showing axios instance with request and response interceptors consumed by multiple micro frontends

Package Configuration

packages/core/api/package.json
{
  "name": "@myapp/api",
  "version": "0.0.1",
  "private": true,
  "main": "index.js"
}

The main: "index.js" tells npm workspaces that index.js is the entry point. When an MFE imports @myapp/api, it resolves to this file.

Core Axios Instance with Interceptors

This is the heart of the shared API layer — a configured axios instance with request interceptors (attach JWT), response interceptors (handle 401 + token refresh), and a failed request queue to prevent race conditions during token refresh.

packages/core/api/index.js
import axios from "axios";
import {
  setSessionExpired,
  setUserInfo,
  store,
  selectAuthToken,
  setAuthToken,
  setIsLoggedIn,
} from "@myapp/store";

export const BASE_URL = "http://localhost:7000";

const axiosInstance = axios.create({
  baseURL: BASE_URL,
  headers: {
    "Content-Type": "application/json",
    Accept: "application/json",
  },
  withCredentials: true,
});

// ── Request Interceptor ──
// Attaches the JWT token from the Redux store to every outgoing request
axiosInstance.interceptors.request.use(
  (config) => {
    const state = store.getState();
    const token = selectAuthToken(state);

    if (token) {
      config.headers["Authorization"] = `Bearer ${token}`;
    }

    config.headers["x-device-info"] = navigator.userAgent;
    return config;
  },
  (error) => Promise.reject(error)
);

// ── Response Interceptor — Token Refresh Queue ──
let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  failedQueue = [];
};

const clearAuthAndRedirect = () => {
  store.dispatch(setAuthToken({ authToken: null }));
  store.dispatch(setIsLoggedIn({ isLoggedIn: false }));
  store.dispatch(setUserInfo({ user: {}, permissions: [] }));

  if (!window.location.pathname.includes("/sign-in")) {
    store.dispatch(setSessionExpired({ sessionExpired: true }));
  }
};

axiosInstance.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401) {
      const errorCode = error.response?.data?.code;

      // These error codes mean the session is invalid — log out immediately
      const logoutCodes = [
        "INVALID_SESSION",
        "USER_INACTIVE",
        "NOT_AUTHENTICATED",
        "INVALID_TOKEN",
        "NO_TOKEN",
        "SESSION_EXPIRED",
      ];

      if (logoutCodes.includes(errorCode)) {
        clearAuthAndRedirect();
        return Promise.reject(error);
      }

      // TOKEN_EXPIRED — try to refresh
      if (errorCode === "TOKEN_EXPIRED" && !originalRequest._retry) {
        // Prevent infinite loop on the refresh endpoint itself
        if (originalRequest.url?.includes("/refresh-token")) {
          clearAuthAndRedirect();
          return Promise.reject(error);
        }

        // Queue concurrent requests while refresh is in progress
        if (isRefreshing) {
          return new Promise((resolve, reject) => {
            failedQueue.push({ resolve, reject });
          }).then((token) => {
            originalRequest.headers["Authorization"] = "Bearer " + token;
            return axiosInstance(originalRequest);
          });
        }

        originalRequest._retry = true;
        isRefreshing = true;

        try {
          const response = await axiosInstance.post(
            "/api/v1/auth/refresh-token"
          );

          if (response.data.success && response.data.accessToken) {
            const { accessToken } = response.data;
            store.dispatch(setAuthToken({ authToken: accessToken }));
            store.dispatch(setIsLoggedIn({ isLoggedIn: true }));
            processQueue(null, accessToken);

            originalRequest.headers["Authorization"] = "Bearer " + accessToken;
            return axiosInstance(originalRequest);
          } else {
            throw new Error("Invalid refresh response");
          }
        } catch (refreshError) {
          processQueue(refreshError, null);
          clearAuthAndRedirect();
          return Promise.reject(refreshError);
        } finally {
          isRefreshing = false;
        }
      }

      clearAuthAndRedirect();
      return Promise.reject(error);
    }

    return Promise.reject(error);
  }
);

export default axiosInstance;

Why a failed request queue? When a token expires, multiple API calls may fail simultaneously with 401. Without the queue, each call would trigger its own token refresh — causing duplicate refresh requests. The queue holds all failed requests, refreshes the token once, then retries them all with the new token.

Domain-Specific API Files

Each business domain gets its own API file (or folder) that imports the shared axios instance. This keeps API functions organized and lets each MFE import only what it needs.

Inventory APIs — used by the Inventory MFE:

packages/core/api/Inventory-Apis/seller-inventory.js
import axiosInstance from "../index.js";

// Centralized error handler for inventory APIs
const handleError = (error, context) => {
  console.error(context, error);
  throw error;
};

export const getSellerInventory = async (params = {}) => {
  try {
    const response = await axiosInstance.get("/inv/api/v1/inventory/products", {
      params: {
        page: params.page || 1,
        limit: params.limit || 20,
        search: params.search,
        status: params.status,
        sortBy: params.sortBy || "created_at",
      },
    });

    if (response.data && response.data.success) {
      return {
        success: response.data.success,
        message: response.data.message || "Inventory retrieved successfully",
        data: response.data.data || [],
        pagination: response.data.pagination,
        summary: response.data.summary,
      };
    }

    return response.data;
  } catch (error) {
    handleError(error, "Error fetching inventory");
  }
};

export const updateVariantInventory = async (variantId, inventoryData) => {
  try {
    const response = await axiosInstance.put(
      `/inv/api/v1/inventory/variant/${variantId}`,
      inventoryData
    );
    return response.data;
  } catch (error) {
    handleError(error, "Error updating variant inventory");
  }
};

export const getInventorySummary = async () => {
  try {
    const response = await axiosInstance.get("/inv/api/v1/inventory/summary");
    return response.data;
  } catch (error) {
    handleError(error, "Error fetching inventory summary");
  }
};

export const downloadImportTemplate = async () => {
  try {
    const response = await axiosInstance.get(
      "/inv/api/v1/inventory/import/template",
      { responseType: "blob" }
    );
    return response.data;
  } catch (error) {
    handleError(error, "Error downloading template");
  }
};

Product APIs — used by the Products MFE for bulk upload:

packages/core/api/Products-Apis/BulkUpload.js
import axiosInstance from "../index.js";

export const BulkUploadAPI = {
  createBulkUpload: async (file, sellerId, additionalData = {}) => {
    const formData = new FormData();
    formData.append("file", file);
    formData.append("sellerId", sellerId);

    if (additionalData.selectedCategory) {
      const templateData = {
        categoryId: additionalData.selectedCategory,
        templateId: additionalData.selectedTemplate?.templateId,
      };
      formData.append("templateData", JSON.stringify(templateData));
    }

    const response = await axiosInstance.post(
      "/master/api/v1/products/bulk/upload",
      formData,
      {
        headers: { "Content-Type": "multipart/form-data" },
        onUploadProgress: (progressEvent) => {
          const percent = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          );
          if (additionalData.onUploadProgress) {
            additionalData.onUploadProgress(percent);
          }
        },
      }
    );

    return response.data;
  },

  getBulkUploadStatus: async (workflowId) => {
    const response = await axiosInstance.get(
      `/master/api/v1/products/bulk/workflow/${workflowId}/status`
    );
    return response.data;
  },
};

Asset APIs — used by any MFE that uploads images or resolves asset URLs:

packages/core/api/assetApis.js
import axiosInstance from "./index.js";

export const getAssetUrl = async (options) => {
  const { assetId, version = "v1", assetType, format } = options;
  const params = new URLSearchParams({ version, assetType, format });

  try {
    const response = await axiosInstance.get(
      `/assets/api/v1/assets/${assetId}/url?${params}`,
      { withCredentials: false }
    );
    return response.data;
  } catch (error) {
    console.error("Error fetching asset URL:", error);
    throw error;
  }
};

export const uploadAsset = async (file, assetType = "product.images.main") => {
  const formData = new FormData();
  formData.append("file", file);
  formData.append("assetType", assetType);

  const response = await axiosInstance.post(
    "/assets/api/v1/assets/upload",
    formData,
    { headers: { "Content-Type": "multipart/form-data" } }
  );

  return response.data;
};
⚠️

Never hardcode API base URLs in individual MFEs. Always use the shared axios instance from @myapp/api. If you need to change the base URL or add a header, you change it once in packages/core/api/index.js and every MFE picks it up automatically.

Package 2 — Shared Redux Store (@myapp/store)

The shared Redux store is the second critical package. In a Micro Frontend architecture, all MFEs must share the same store instance — otherwise, auth state, user data, and permissions get out of sync across modules. This follows the singleton pattern (opens in a new tab) enforced by Redux Toolkit (opens in a new tab).

Shared Redux store singleton pattern showing one store instance shared across all micro frontends via Module Federation

Package Configuration

packages/core/store/package.json
{
  "name": "@myapp/store",
  "version": "0.0.1",
  "private": true,
  "main": "index.js",
  "dependencies": {
    "@reduxjs/toolkit": "^2.6.0",
    "react-redux": "^9.2.0"
  }
}

Note that @reduxjs/toolkit and react-redux are listed as dependencies here, not in individual MFE package.json files. The store package owns these dependencies.

Store Configuration

packages/core/store/store.js
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "./slices/authSlice";

export const store = configureStore({
  reducer: {
    auth: authReducer,
  },
});

Custom Hooks

packages/core/store/hooks.js
import { useDispatch, useSelector } from "react-redux";

export const useAppDispatch = () => useDispatch();
export const useAppSelector = useSelector;

These typed hooks provide a cleaner import pattern and make it easy to add TypeScript types later without changing every consumer.

Auth Slice — Shared Authentication State

The auth slice manages login state, user info, permissions, and session expiration across all MFEs. When any MFE dispatches setAuthToken, every other MFE that reads selectAuthToken gets the updated value instantly — because they all share the same singleton store.

packages/core/store/slices/authSlice.js
import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  isLoggedIn: false,
  user: null,
  sellerProfile: null,
  permissions: [],
  loading: true,
  authToken: null,
  sessionExpired: false,
};

const authSlice = createSlice({
  name: "auth",
  initialState,
  reducers: {
    setSellerProfile: (state, action) => {
      state.sellerProfile = action.payload.sellerProfile;
    },

    setLoading: (state, action) => {
      state.loading = action.payload;
    },

    updateUser: (state, action) => {
      if (state.user) {
        state.user = { ...state.user, ...action.payload };
      }
    },

    setIsLoggedIn: (state, action) => {
      state.isLoggedIn = action.payload.isLoggedIn;
    },

    setUserInfo: (state, action) => {
      state.user = action.payload.user;
      state.permissions = action.payload.permissions || [];
    },

    setAuthToken: (state, action) => {
      state.authToken = action.payload.authToken;
    },

    setSessionExpired: (state, action) => {
      state.sessionExpired = action.payload.sessionExpired;
      if (action.payload.sessionExpired) {
        state.isLoggedIn = false;
        state.user = null;
        state.authToken = null;
      }
    },
  },
});

export const {
  setIsLoggedIn,
  setUserInfo,
  setAuthToken,
  setSessionExpired,
  setLoading,
  updateUser,
  setSellerProfile,
} = authSlice.actions;

// ── Selectors ──
export const selectAuth = (state) => state.auth;
export const selectIsLoggedIn = (state) => state.auth.isLoggedIn;
export const selectUser = (state) => state.auth.user;
export const selectAuthLoading = (state) => state.auth.loading;
export const selectAuthToken = (state) => state.auth.authToken;
export const selectPermissions = (state) => state.auth.permissions;
export const selectSessionExpired = (state) => state.auth.sessionExpired;
export const selectSellerId = (state) =>
  state.auth.sellerProfile?.seller_registration_id || null;

export default authSlice.reducer;

Store Entry Point — Barrel Export

The index.js re-exports everything MFEs need from a single import path. This is the barrel export pattern — MFEs import from @myapp/store instead of reaching into internal files.

packages/core/store/index.js
// Re-export everything MFEs need from the shared store
export { store } from "./store";
export { Provider } from "react-redux";
export { useAppDispatch, useAppSelector } from "./hooks";

// Auth slice exports
export {
  setIsLoggedIn,
  setUserInfo,
  setAuthToken,
  setSessionExpired,
  setLoading,
  updateUser,
  setSellerProfile,
  selectAuth,
  selectIsLoggedIn,
  selectUser,
  selectAuthLoading,
  selectAuthToken,
  selectPermissions,
  selectSessionExpired,
  selectSellerId,
} from "./slices/authSlice";

This means an MFE can do:

import { store, Provider, useAppSelector, selectIsLoggedIn } from "@myapp/store";

Instead of importing from 4 different files. Clean, simple, consistent.

Package 3 — Shared UI Components (@myapp/uicomponents)

The UI components package contains reusable React components that every MFE uses — toast notifications, buttons, cards, and other design system elements.

Package Configuration

packages/uicomponents/package.json
{
  "name": "@myapp/uicomponents",
  "version": "0.0.1",
  "private": true,
  "main": "src/index.js"
}

Custom Toast Component

packages/uicomponents/src/CustomToast.jsx
import React from "react";
import { Toaster } from "react-hot-toast";

const CustomToast = () => {
  return (
    <Toaster
      position="top-right"
      reverseOrder={false}
      toastOptions={{
        duration: 3000,
        style: { background: "#363636", color: "#fff" },
        success: {
          style: { background: "#4ade80", color: "#fff" },
        },
        error: {
          style: { background: "#ef4444", color: "#fff" },
        },
      }}
    />
  );
};

export default CustomToast;

Entry Point

packages/uicomponents/src/index.js
// packages/uicomponents/src/index.js
export { default as CustomToast } from "./CustomToast";
export * from "./button";
export * from "./card";

Webpack Alias — How MFEs Find Shared Packages

The critical piece that makes this work is the Webpack resolve alias configuration. Without it, import { store } from "@myapp/store" would fail because Webpack doesn't know where @myapp/store lives on disk.

Local and Production Webpack configs use the same alias paths — the monorepo folder structure is identical in both environments. The only difference is the Module Federation remote URLs (localhost ports vs Nginx paths).

apps/host/webpack.config.js
const path = require("path");
const { ModuleFederationPlugin } = require("webpack").container;
const dependencies = require("./package.json").dependencies;

module.exports = {
  // ... other config
  resolve: {
    extensions: [".js", ".jsx", ".json"],
    alias: {
      // Webpack resolves @myapp/api and @myapp/store
      // to the actual package folders in the monorepo
      "@myapp/api": path.resolve(__dirname, "../../packages/core/api"),
      "@myapp/store": path.resolve(__dirname, "../../packages/core/store"),
    },
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "HostApp",
      filename: "remoteEntry.js",
      remotes: {
        Products:  "Products@https://localhost:4002/remoteEntry.js",
        Orders:    "Orders@https://localhost:4004/remoteEntry.js",
        Inventory: "Inventory@https://localhost:4003/remoteEntry.js",
        // ... other remotes on their own ports
      },
      shared: {
        react:              { singleton: true, requiredVersion: dependencies.react },
        "react-dom":        { singleton: true, requiredVersion: dependencies["react-dom"] },
        "react-router-dom": { singleton: true, requiredVersion: dependencies["react-router-dom"] },
        "@reduxjs/toolkit":  { singleton: true, requiredVersion: "^2.6.0" },
        "react-redux":       { singleton: true, requiredVersion: "^9.2.0" },
        // The shared store package — singleton ensures ONE store instance
        "@myapp/store":      { singleton: true, requiredVersion: "0.0.1" },
      },
    }),
  ],
};

Why Both Aliases AND Module Federation shared?

MechanismWhat It Does
resolve.aliasTells Webpack where to find the package at build time — resolves @myapp/store to ../../packages/core/store
shared + singletonTells Module Federation to share one instance at runtime — prevents each MFE from loading its own copy

You need both. Aliases alone would bundle the package into every MFE separately. Module Federation shared alone wouldn't resolve the import path. Together, they give you a single shared instance across all MFEs.

How MFEs Consume Shared Packages

App Entry Point — Wrapping with Provider and Store

Every MFE wraps its root component with the shared Provider and store from @myapp/store:

apps/products/src/App.jsx
// apps/products/src/App.jsx
import React from "react";
import { BrowserRouter } from "react-router-dom";
import { Provider, store } from "@myapp/store";
import ProductRoutes from "./ProductRoutes.jsx";
import "./index.css";

function App() {
  return (
    <Provider store={store}>
      <BrowserRouter>
        <ProductRoutes />
      </BrowserRouter>
    </Provider>
  );
}

export default App;

Component Usage — API + Store Together

Here's a real example showing how an MFE component uses both the shared API layer and shared store in a single component:

apps/inventory/src/pages/InventoryDashboard.jsx
// apps/inventory/src/pages/InventoryDashboard.jsx
import React, { useEffect, useState } from "react";
import { useAppSelector, selectIsLoggedIn } from "@myapp/store";
import {
  getSellerInventory,
  getInventorySummary,
} from "@myapp/api/Inventory-Apis/seller-inventory";

function InventoryDashboard() {
  const isLoggedIn = useAppSelector(selectIsLoggedIn);
  const [inventory, setInventory] = useState([]);
  const [summary, setSummary] = useState(null);

  useEffect(() => {
    if (!isLoggedIn) return;

    const loadData = async () => {
      const [invResult, summaryResult] = await Promise.all([
        getSellerInventory({ page: 1, limit: 20 }),
        getInventorySummary(),
      ]);
      setInventory(invResult.data);
      setSummary(summaryResult.data);
    };

    loadData();
  }, [isLoggedIn]);

  return (
    <div>
      <h1>Inventory Dashboard</h1>
      {summary && (
        <div className="grid grid-cols-3 gap-4">
          <div>Total Products: {summary.totalProducts}</div>
          <div>In Stock: {summary.inStock}</div>
          <div>Low Stock: {summary.lowStock}</div>
        </div>
      )}
      {/* ... render inventory table */}
    </div>
  );
}

export default InventoryDashboard;

Notice how the component:

  1. Imports useAppSelector and selectIsLoggedIn from @myapp/store — shared auth state
  2. Imports API functions from @myapp/api/Inventory-Apis/seller-inventory — shared API layer
  3. Both work together seamlessly — the API layer reads the token from the same store

Summary — Shared Package Checklist

PackagePurposeKey FilesConsumed Via
@myapp/apiAxios instance + interceptors + domain APIsindex.js, Inventory-Apis/, Products-Apis/import axiosInstance from "@myapp/api"
@myapp/storeRedux store + auth slice + hooks + selectorsstore.js, hooks.js, slices/authSlice.jsimport { store, Provider } from "@myapp/store"
@myapp/uicomponentsShared React components (Toast, Button, Card)src/index.js, src/CustomToast.jsximport { CustomToast } from "@myapp/uicomponents"

All three packages follow the same pattern: private npm workspace packageWebpack alias for import resolutionModule Federation shared + singleton for runtime deduplication.

What's Next?

This article covered how to create and consume shared packages in a Micro Frontend monorepo. In the next article, we'll dive into the complete Webpack 5 configuration — covering entry/output, Babel, CSS, dev server, code splitting, and optimization settings for MFE builds.

← Back to React MFE Monorepo Setup

Continue to Webpack 5 Configuration for Micro Frontends →


Frequently Asked Questions

What are shared packages in a micro frontend monorepo?

Shared packages are internal libraries inside a monorepo that multiple micro frontends consume. Common examples include a shared API layer (axios instance with interceptors), a shared Redux store, and shared UI components. They live in the packages/ directory and are linked via npm workspaces.

How do MFEs consume shared packages without duplicating code?

MFEs consume shared packages through two mechanisms: Webpack resolve aliases map import paths like @myapp/store to the actual package folder, and Module Federation's shared config with singleton: true ensures only one instance of the package loads at runtime, preventing duplication.

Why is singleton: true important for shared packages in Module Federation?

Without singleton: true, each MFE would load its own copy of the shared package. For a Redux store, this means each MFE has a separate store instance and state changes in one MFE are invisible to others. Singleton ensures all MFEs share the exact same instance at runtime.

Should I publish shared packages to npm in a micro frontend monorepo?

No. In a monorepo, shared packages are private ("private": true in package.json) and resolved locally via npm workspaces and Webpack aliases. Publishing to npm adds versioning overhead and deployment complexity. Keep them internal — npm workspaces handle the linking automatically.

How do I organize API functions across multiple domains in a shared API package?

Create a folder per business domain inside the API package: Inventory-Apis/, Products-Apis/, Order-Apis/, Pricing-Apis/, etc. Each folder contains a single file with all API functions for that domain. The root index.js holds the shared axios instance with interceptors. MFEs import only the domain APIs they need.

Can shared packages work with both React and Next.js micro frontends?

Yes. Shared packages are plain JavaScript or React code. A Redux store or API layer works identically in React (Webpack) and Next.js (NextFederationPlugin) MFEs. The only difference is the Webpack config — React uses ModuleFederationPlugin while Next.js uses NextFederationPlugin, but both support the same shared dependency configuration.