Shared Redux Store in Next.js Module Federation

Published: May 1, 2026 · 18 min read

Shared Redux Store in Next.js Module Federation: Cross-Remote State

You log in through the Auth React micro frontend at /login, then navigate to /products. The Products Next.js remote loads, and its header still shows the Login button — even though you literally just logged in. Open Redux DevTools and you see two stores: one created by the Auth remote, one created by the Products remote. Each remote dispatched into its own store and the other never knew. This is the cross-cutting state problem in a hybrid micro frontend, and the fix is a properly federated Redux store next.js module federation singleton — one store, shared by every React and Next.js remote in the application. In the previous article on mixing React and Next.js micro frontends, you saw the dual-plugin host configuration that loads both remote types. This article covers the singleton store package, the Provider setup that prevents window-related SSR crashes, the strictVersion contract that keeps deployed remotes in sync, and the seven gotchas that account for almost every "state is not propagating" bug.

In this guide, you will:

  • See the complete store architecture for a hybrid Next.js host loading React and Next.js remotes
  • Build a federated @myapp/store package with configureStore, slices, and re-exported react-redux primitives
  • Wire up the ClientReduxProvider wrapper with ssr: false so the federated container loads only in the browser
  • Mirror the singleton declaration across the host (NextFederationPlugin) and every remote (ModuleFederationPlugin + NextFederationPlugin)
  • Compare local development vs production webpack/next.config blocks for both remote types
  • Read and write the same store from a React remote (OTPVerify) and a Next.js remote (ProductCard)
  • Understand why each remote keeps its own ClientReduxProvider for standalone development
  • Avoid the seven federated-Redux gotchas that surface only in production

Shared Redux store in Next.js module federation architecture diagram showing one singleton store federated across React and Next.js micro frontends

The Cross-Remote State Problem

A hybrid micro frontend has authentication state, cart state, support tickets, and chat messages — all of which need to be visible to multiple remotes. The user logs in through the Auth remote, adds a product to the cart from the Products remote, opens a support ticket through the Support remote, and the header on the host's home page must reflect all three. There is no parent component to lift state into; the remotes are loaded lazily and may not even be on screen at the same time.

The naive options all fail:

  • Prop drilling is impossible — the host does not directly render every remote's children.
  • localStorage polling is racy and forces every remote to re-implement the same read/write logic.
  • Custom event buses require every remote to subscribe to every event, and there is no schema enforcement.
  • Per-remote stores create the exact problem from the first paragraph — state mutations stay isolated to whichever store the dispatch happened to hit.

A single Redux store, federated as a Module Federation singleton, sidesteps all four. The host owns the store. Every remote imports useSelector and useDispatch from the same federated package. Module Federation's runtime guarantees there is exactly one store instance regardless of how many remotes are mounted.

The same pattern works for any global state library. Zustand, Jotai, and TanStack Query store instances all federate the same way as long as the store creation function lives inside a singleton package. Redux Toolkit is the example here because it is what the real codebase uses — see Module Federation shared dependencies for the underlying mechanism.

Federated Store Architecture

The architecture below shows the host owning a single store instance and three remotes — one React (Auth) and two Next.js (Products, Content) — all consuming the same store via Module Federation singletons. The store package is published as a workspace dependency at version 1.0.0, locked across every webpack and next.config block.

Federated Redux store overview
# Redux Store Singleton Across a Hybrid Next.js MFE
# ─────────────────────────────────────────────────────
#
#   ┌────────────────────────────────────────────────────────────────┐
#   │  HOST — Main (Next.js + NextFederationPlugin)                   │
#   │                                                                  │
#   │   pages/_app.tsx                                                 │
#   │     └─ <ClientReduxProvider>          ← creates store ONCE       │
#   │           └─ <Component {...pageProps} />                        │
#   │                                                                  │
#   │   The store instance lives in window memory after hydration.     │
#   │   Every remote that imports '@myapp/store' gets THIS instance.   │
#   └────────────────────────────────────────────────────────────────┘
#                              │
#       ┌──────────────────────┼──────────────────────┐
#       │                      │                      │
#       ▼                      ▼                      ▼
#   ┌─────────────┐      ┌─────────────┐      ┌──────────────┐
#   │ React       │      │ Next.js     │      │ Next.js      │
#   │ remote      │      │ remote      │      │ remote       │
#   │ (Auth)      │      │ (Products)  │      │ (Content)    │
#   │             │      │             │      │              │
#   │ dispatch(   │      │ useSelector │      │ useSelector  │
#   │   setUser)  │      │   .cart     │      │   .user      │
#   └─────────────┘      └─────────────┘      └──────────────┘
#
# What Module Federation guarantees with singleton: true:
#   - First remote that needs '@myapp/store' triggers the load
#   - Host's already-loaded copy wins because it boots first
#   - Every later import returns the SAME store reference
#   - dispatch() in Auth → useSelector() in Products sees the change

The crucial property is that the store is created once, in the host, on the client. Every remote that later imports from the store package receives the host's instance, not a fresh copy. Module Federation's singleton: true flag is what makes this guarantee binding — without it, each remote would silently bundle its own copy and you would be back to the cross-remote state problem.

Step 1 — Build the Federated Store Package

The store lives in a workspace package — the same Turborepo or pnpm workspace pattern from the React MFE monorepo guide. Three files matter: package.json (versioning contract), src/store.js (the configureStore call), and index.js (the public API every remote imports).

Package manifest with version locking

The package.json is small but every field matters. The version field is what Module Federation matches against requiredVersion: '1.0.0' in every remote's shared block. The peerDependencies declare React without bundling it, so the federated singleton React from the host is the one that gets used.

packages/store/package.json
{
  "name": "@myapp/store",
  "version": "1.0.0",
  "main": "./index.js",
  "types": "./index.d.ts",
  "scripts": {
    "build": "tsc"
  },
  "dependencies": {
    "@reduxjs/toolkit": "^2.0.1",
    "react-redux": "^9.0.4"
  },
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}

// Why version 1.0.0 is locked:
//   The host AND every remote declare requiredVersion: '1.0.0'
//   with strictVersion: true. Bumping this number forces every
//   remote to be rebuilt — Module Federation refuses to load a
//   1.1.0 store next to a 1.0.0 store. That guarantee is what
//   keeps the slice shapes identical across remotes deployed at
//   different times.

Store factory and singleton

configureStore runs once at module load. The makeStore() factory exists to support per-request server stores (used in rare SSR cases), and store is the default browser singleton that the host imports.

packages/store/src/store.js
// packages/store/src/store.js
import { configureStore } from '@reduxjs/toolkit';
import { userSlice } from './slices/userSlice';
import { cartSlice } from './slices/cartSlice';
import { ticketSlice } from './slices/ticketSlice';
import { chatSlice } from './slices/chatSlice';

// makeStore() exists so SSR can build a fresh store per request
// when needed. The default export 'store' is the browser singleton.
export const makeStore = () => {
  return configureStore({
    reducer: {
      user:    userSlice.reducer,
      cart:    cartSlice.reducer,
      tickets: ticketSlice.reducer,
      chat:    chatSlice.reducer,
    },
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware({
        serializableCheck: false,   // <- Required: cart items carry Date objects
      }),
  });
};

// Single browser instance — the host imports this directly.
export const store = makeStore();

// Server-side store factory — used only if a page opts into per-request SSR.
export const makeServerStore = () => makeStore();

serializableCheck: false is required because the cart slice carries Date objects in some payloads. Without it, Redux Toolkit emits a warning on every dispatch — harmless, but noisy. The trade-off is that you must not store non-serializable values you cannot recreate from JSON.

A representative slice

The user slice owns authentication state. The Auth remote dispatches setAt, setIsLoggedIn, and setUser. Every other remote — including Next.js remotes — reads with selectors like selectIsLoggedIn and selectAccessToken.

packages/store/src/slices/userSlice.js
// packages/store/src/slices/userSlice.js — Auth state used by every remote
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  firstName: '',
  lastName: '',
  name: '',
  email: '',
  phoneNumber: null,
  avatarUrl: null,
  at: null,                 // <- access token — read by axios interceptor
  isLoggedIn: false,
  profileUpdating: false,
  address: null,
};

export const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    setUser: (state, action) => {
      state.firstName = action.payload.firstName;
      state.lastName  = action.payload.lastName;
      state.name      = action.payload.name;
      state.email     = action.payload.email;
      state.phoneNumber = action.payload.phoneNumber || null;
      state.avatarUrl   = action.payload.avatarUrl || null;
    },
    setAt: (state, action) => {
      state.at = action.payload.at;
    },
    setIsLoggedIn: (state, action) => {
      state.isLoggedIn = action.payload.isLoggedIn;
    },
    clearUser: (state) => {
      state.firstName = '';
      state.lastName  = '';
      state.name      = '';
      state.email     = '';
      state.at        = null;
      state.isLoggedIn = false;
      state.address   = null;
    },
  },
});

export const selectUser        = (state) => state.user;
export const selectIsLoggedIn  = (state) => state.user.isLoggedIn;
export const selectAccessToken = (state) => state.user.at;
export const selectUserName    = (state) => state.user.name;
export const selectEmail       = (state) => state.user.email;

export const { setUser, setAt, setIsLoggedIn, clearUser } = userSlice.actions;
export default userSlice.reducer;

The selectors are the API surface that remotes depend on. Adding a new field to initialState is safe; renaming a field requires bumping version because every consumer references the field by name.

Public API — what remotes actually import

The index.js is the only entry point remotes touch. It re-exports actions, selectors, hooks, and — critically — Provider, useSelector, and useDispatch from react-redux. Routing those primitives through the store package guarantees that every remote uses the same federated react-redux instance.

packages/store/index.js
// packages/store/index.js — Public API of the federated store package
// Every export here is what remotes (React or Next.js) import.

// ── Store singleton + factories ─────────────────────────────
export { store, makeStore, makeServerStore } from './src/store';

// ── User slice — actions, selectors ─────────────────────────
export {
  userSlice,
  setUser,
  clearUser,
  setAt,
  setIsLoggedIn,
  setProfileUpdating,
  setAddress,
  selectUser,
  selectIsLoggedIn,
  selectAccessToken,
  selectEmail,
  selectUserName,
  selectPhoneNumber,
  selectAddress,
} from './src/slices/userSlice';

// ── Cart slice — actions, selectors ─────────────────────────
export {
  cartSlice,
  addToCart,
  removeFromCart,
  updateQuantity,
  selectItem,
  deselectItem,
  selectAllItems,
  applyCoupon,
  removeCoupon,
  initializeCart,
  clearCart,
  setTotalItems,
  selectTotalCartItems,
} from './src/slices/cartSlice';

// ── App-typed dispatch and selector hooks ──────────────────
export { useAppDispatch, useAppSelector } from './src/hooks';

// ── Re-exported react-redux primitives so remotes do NOT
//    need their own react-redux dependency at runtime.
export { Provider, useDispatch, useSelector } from 'react-redux';

// Why re-export Provider here instead of letting remotes
// import 'react-redux' directly?
// - Some remotes are built without react-redux as a top-level
//   dependency. Routing them through @myapp/store guarantees
//   they always pick up the SAME negotiated singleton.
// - The host can swap react-redux for a wrapper later without
//   touching every remote.

Step 2 — Wrap the Host with ClientReduxProvider

The host is a Next.js application. The Provider can only run on the client because Module Federation containers attach to window. If _app.tsx imports Provider directly at the top, Next.js's server build crashes with ReferenceError: window is not defined the moment the federated store package loads.

The solution is a thin wrapper component imported with next/dynamic and ssr: false.

apps/Main/components/ClientReduxProvider.tsx
// apps/Main/components/ClientReduxProvider.tsx — Wraps the app on the client
'use client';
import { ReactNode } from 'react';
import { Provider, store } from '@myapp/store';

interface ClientReduxProviderProps {
  children: ReactNode;
}

export default function ClientReduxProvider({ children }: ClientReduxProviderProps) {
  return (
    <Provider store={store}>
      {children}
    </Provider>
  );
}

// Why a wrapper component instead of importing Provider directly in _app.tsx?
//
// 1. Module Federation containers attach to window. The store is a
//    federated singleton — it must initialize AFTER the browser bootstraps.
//    Wrapping the Provider in its own component lets _app.tsx import it
//    via next/dynamic with ssr: false (see _app.tsx below).
//
// 2. 'use client' marks the file for the App Router migration path. Pages
//    Router ignores it; App Router treats it as a client boundary.
//
// 3. Centralizing the import means every remote that needs <Provider>
//    points at @myapp/store — never react-redux directly. That keeps the
//    singleton negotiation honest.

_app.tsx then loads ClientReduxProvider dynamically:

apps/Main/pages/_app.tsx
// apps/Main/pages/_app.tsx — Pages Router host
import React from 'react';
import type { AppProps } from 'next/app';
import dynamic from 'next/dynamic';
import '../styles/globals.css';

// CRITICAL: ssr: false on the Redux Provider wrapper.
//
// The federated @myapp/store can only initialize in the browser
// because Module Federation containers register on window.
// Importing the Provider with ssr: false ensures Next.js never
// tries to render <Provider store={store}> on the server.
const ClientReduxProvider = dynamic(
  () => import('../components/ClientReduxProvider'),
  { ssr: false, loading: () => null }
);

const Layout = dynamic(() => import('../components/Layout'), {
  ssr: false,
  loading: () => <div>Loading...</div>,
});

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ClientReduxProvider>
      <Layout>
        <Component {...pageProps} />
      </Layout>
    </ClientReduxProvider>
  );
}

export default MyApp;

// What you do NOT do here:
//   1. import { Provider, store } from '@myapp/store'  at the top
//      → That executes during Next.js's server build and crashes
//        with 'window is not defined' the moment Module Federation
//        tries to attach the container.
//
//   2. Wrap pages individually with their own <Provider>
//      → Two providers means two stores. Auth dispatches into
//        store A, Products reads store B — bug surfaces only
//        on routes that mount remotes from different sections.
⚠️

Do NOT import Provider or store at the top of _app.tsx. The server build evaluates that import during next build and crashes inside packages/store/index.js. Always go through a next/dynamic wrapper with ssr: false. This is the same pattern from the SSR vs CSR Next.js Module Federation article — federated containers cannot run on the server.

Step 3 — Declare the Singleton in the Host's next.config.js

The host's NextFederationPlugin shared block is where the singleton contract is signed. Five packages need the singleton flag, and three need strict versioning.

apps/Main/next.config.js
// apps/Main/next.config.js — only the shared block matters here
const { NextFederationPlugin } = require('@module-federation/nextjs-mf');

module.exports = {
  reactStrictMode: false,
  output: 'standalone',
  transpilePackages: ['@myapp/seo', '@myapp/store', '@myapp/api'],

  webpack(config, { isServer }) {
    if (!isServer) {
      config.resolve.fallback = {
        ...config.resolve.fallback,
        fs: false, stream: false, zlib: false,
      };
    }

    config.plugins.push(
      new NextFederationPlugin({
        name: 'Main',
        filename: 'static/chunks/remoteEntry.js',

        remotes: {
          // React remotes — single remoteEntry.js
          Auth:    'Auth@https://dev.myapp.com/auth/remoteEntry.js',
          Account: 'Account@https://dev.myapp.com/account/remoteEntry.js',
          Cart:    'Cart@https://dev.myapp.com/cart/remoteEntry.js',
          Support: 'Support@https://dev.myapp.com/support/remoteEntry.js',

          // Next.js remotes — dual remoteEntry.js (server + client)
          Content: `Content@https://dev.myapp.com/content/_next/static/${
            isServer ? 'ssr' : 'chunks'
          }/remoteEntry.js`,
          Products: `Products@https://dev.myapp.com/products/_next/static/${
            isServer ? 'ssr' : 'chunks'
          }/remoteEntry.js`,
        },

        // ── THIS IS WHERE THE REDUX SINGLETON IS NEGOTIATED ──────────
        shared: {
          react:              { singleton: true, requiredVersion: false, eager: false },
          'react-dom':        { singleton: true, requiredVersion: false, eager: false },
          'react-redux':      { singleton: true, requiredVersion: false, eager: false },
          '@reduxjs/toolkit': { singleton: true, requiredVersion: false, eager: false },

          '@myapp/store': {
            singleton: true,
            strictVersion: true,
            requiredVersion: '1.0.0',
          },
          '@myapp/api':   {
            singleton: true,
            strictVersion: true,
            requiredVersion: '1.0.0',
          },
        },

        extraOptions: {
          exposePages: true,
          enableImageLoaderFix: true,
          enableUrlLoaderFix: true,
          automaticAsyncBoundary: true,
        },
      })
    );

    return config;
  },
};

// Why every shared key matters for the Redux store:
// - react / react-dom / react-redux / @reduxjs/toolkit: singleton: true
//   prevents two React copies. Two Reacts = two contexts = two stores.
// - @myapp/store: strictVersion + requiredVersion: '1.0.0' fails the
//   build if any remote ships a different store version. No silent drift.

The shared block is the entire reason the federation works. react, react-dom, react-redux, and @reduxjs/toolkit use requiredVersion: false because Next.js bundles its own React version and we let the host's version win. @myapp/store and @myapp/api use strictVersion: true because they are in-house packages — any drift between deployments is a bug, not a tolerable variation.

Step 4 — Mirror the Singleton in Every Remote

Every remote — React or Next.js — must declare the same shared block. Module Federation matches package names as literal strings, so a typo creates a second store. Local development and production configs differ in mode, output paths, and dev server settings — but the shared block is identical across both.

apps/Products/next.config.js
// apps/Products/next.config.js — Next.js remote shared block
const NextFederationPlugin = require('@module-federation/nextjs-mf');

module.exports = {
  reactStrictMode: true,
  output: 'standalone',

  basePath: '/products',
  assetPrefix: '/products',

  transpilePackages: ['@myapp/seo', '@myapp/store', '@myapp/api'],

  webpack(config, { isServer }) {
    config.plugins.push(
      new NextFederationPlugin({
        name: 'Products',
        filename: 'static/chunks/remoteEntry.js',
        exposes: {
          './BeautyCare':        './components/BeautyCare.tsx',
          './ProductDetailPage': './pages/products/index.tsx',
        },

        // The shared block here MUST match the host's shared block.
        // Module Federation matches by package name string — any
        // mismatch creates a duplicate copy.
        shared: {
          react:              { singleton: true, requiredVersion: false, eager: false },
          'react-dom':        { singleton: true, requiredVersion: false, eager: false },
          'react-redux':      { singleton: true, requiredVersion: false, eager: false },
          '@reduxjs/toolkit': { singleton: true, requiredVersion: false, eager: false },

          '@myapp/store': {
            singleton: true,
            strictVersion: true,
            requiredVersion: '1.0.0',
          },
          '@myapp/api':   {
            singleton: true,
            strictVersion: true,
            requiredVersion: '1.0.0',
          },
        },

        extraOptions: {
          automaticAsyncBoundary: true,
        },
      })
    );

    return config;
  },
};

// What Module Federation does at runtime:
//
// 1. Browser loads the host's main bundle.
//    - Host imports '@myapp/store' from packages/store
//    - Host registers '@myapp/store@1.0.0' in the shared registry
//
// 2. User navigates to /products
//    - Host fetches Products's chunks/remoteEntry.js
//    - Products's shared registry says: 'I need @myapp/store@1.0.0'
//    - Host's registry replies: 'Already loaded — here is the reference'
//    - Products's code receives the host's store, not a new one
//
// 3. Products component renders
//    - useSelector reads from the host's store
//    - dispatch updates the host's store
//    - Auth remote (loaded earlier) sees the same change

The crucial difference between the React local and production configs is everything outside the shared block — mode: 'development', publicPath: 'https://localhost:4101/', an HTTPS dev server with CORS, and splitChunks: false for fast HMR. The shared block stays identical, which is why the singleton contract holds across both environments.

Federation Negotiation — What Happens at Runtime

The configuration above is declarative. The actual store-sharing happens at runtime, in five steps, when the host loads its first remote.

StepWhat happensWhere it runs
1Host's main bundle imports @myapp/storeBrowser, host main chunk
2Host registers @myapp/store@1.0.0 in the shared module registryModule Federation runtime
3User navigates to /products — host fetches Products's chunks/remoteEntry.jsBrowser, dynamic import
4Products's shared registry says it needs @myapp/store@1.0.0 (singleton, strict)Module Federation runtime
5Host's registry returns the already-loaded reference; Products skips loading its own copyModule Federation runtime

The same five steps run when the host loads a React remote (Auth) — ModuleFederationPlugin and NextFederationPlugin use the exact same negotiation API. The fact that one was built with webpack standalone and the other was built through Next.js does not matter; they both register against the same shared module registry.

Module Federation Redux singleton negotiation flow showing host registering the store and remotes receiving the same reference

Step 5 — Use the Store Inside a React Remote

The Auth React remote dispatches into the federated store. Notice every import goes through @myapp/store — never react-redux directly.

apps/Auth/src/components/OTPVerify.jsx
// apps/Auth/src/components/OTPVerify.jsx — React remote dispatching to the singleton
import React, { useState, useRef } from 'react';
import { handleVerifyMobileOTP } from '@myapp/api/auth.js';
import {
  setAt,
  setIsLoggedIn,
  useAppDispatch,
  useAppSelector,
  selectUser,
} from '@myapp/store';

const OTPVerify = ({ onNavigate }) => {
  const dispatch = useAppDispatch();
  const { at, isLoggedIn } = useAppSelector(selectUser);

  const [otp, setOtp] = useState(['', '', '', '', '', '']);
  const inputRefs = useRef([]);
  const mobileNumber = sessionStorage.getItem('mobileNumber') || '';

  const verifyOTP = async () => {
    const code = otp.join('');
    const response = await handleVerifyMobileOTP({ mobile: mobileNumber, code });

    if (response?.token) {
      // Dispatching into the host's store from a React remote.
      // Within milliseconds, every Next.js remote that has a
      // useSelector(selectIsLoggedIn) re-renders with the new value.
      dispatch(setAt({ at: response.token }));
      dispatch(setIsLoggedIn({ isLoggedIn: true }));

      onNavigate?.('/');
    }
  };

  return (
    <div>
      {/* OTP input UI */}
      <button onClick={verifyOTP}>Verify</button>
    </div>
  );
};

export default OTPVerify;

// Notice what is NOT here:
// - No new <Provider> wrapping this component
// - No createStore() call
// - No useSelector imported from 'react-redux' directly
//
// Everything goes through @myapp/store. That single import path
// is the entire reason this remote and the host share state.

When verifyOTP runs and dispatches setIsLoggedIn({ isLoggedIn: true }), the action lands in the host's store. Within milliseconds, every Next.js remote currently mounted re-renders with the new value. The Auth remote does not know any other remote exists — it just dispatches into its imported store and React-Redux handles the subscribe/notify.

Step 6 — Use the Store Inside a Next.js Remote

The Products Next.js remote uses the same hooks the same way. The only difference from the React remote is the 'use client' directive and TypeScript annotations.

apps/Products/components/ProductCard.tsx
// apps/Products/components/ProductCard.tsx — Next.js remote reading + writing the singleton
'use client';
import {
  useAppDispatch,
  useAppSelector,
  selectIsLoggedIn,
  selectAccessToken,
  addToCart,
} from '@myapp/store';

interface ProductCardProps {
  id: string;
  name: string;
  price: number;
  image: string;
}

export default function ProductCard({ id, name, price, image }: ProductCardProps) {
  const dispatch = useAppDispatch();
  const isLoggedIn = useAppSelector(selectIsLoggedIn);
  const accessToken = useAppSelector(selectAccessToken);

  const handleAddToCart = () => {
    if (!isLoggedIn) {
      // Bridge the user back to the Auth remote — host owns routing
      window.location.href = '/login';
      return;
    }

    // The accessToken is the SAME one the Auth remote dispatched
    // five minutes ago when the user logged in. No prop drilling,
    // no event bus, no localStorage round-trip.
    dispatch(addToCart({
      id,
      name,
      price,
      qty: 1,
      authToken: accessToken,
    }));
  };

  return (
    <div className="product-card">
      <img src={image} alt={name} />
      <h3>{name}</h3>
      <p>{price}</p>
      <button onClick={handleAddToCart}>Add to Cart</button>
    </div>
  );
}

// Same selectors as the Auth remote, same dispatch.
// The proof that the singleton is working: log in via Auth,
// open Redux DevTools attached to the host, then navigate to
// /products — Products's ProductCard sees user.isLoggedIn = true
// without any explicit message-passing.

The proof that the singleton is working: log in through the Auth remote, then navigate to /products. ProductCard.handleAddToCart reads accessToken from the same store the Auth remote dispatched into five seconds ago — no event bus, no localStorage, no prop chain.

Why Each Remote Keeps Its Own ClientReduxProvider

Open any remote in this codebase and you will find a ClientReduxProvider.tsx file that looks identical to the host's. New developers usually flag this as duplication — but the duplication is intentional.

apps/Products/components/ClientReduxProvider.tsx
// apps/Products/components/ClientReduxProvider.tsx — A SUBTLE TRAP
//
// Every remote in this codebase has a copy of this file. It looks like
// the host's ClientReduxProvider — and that's the point.
'use client';
import { ReactNode } from 'react';
import { Provider, store } from '@myapp/store';

interface ClientReduxProviderProps {
  children: ReactNode;
}

export default function ClientReduxProvider({ children }: ClientReduxProviderProps) {
  return (
    <Provider store={store}>
      {children}
    </Provider>
  );
}

// WHY DOES EACH REMOTE HAVE THIS FILE EVEN THOUGH THE HOST WRAPS THE APP?
//
// Two reasons:
//
// 1. STANDALONE DEVELOPMENT
//    Each remote can be run independently for local development:
//      cd apps/Products && next dev -p 3001
//    When you visit http://localhost:3001/products, there is no host
//    to provide a <Provider>. The remote's pages/_app.tsx wraps itself
//    with this ClientReduxProvider so useSelector still works.
//
// 2. WHEN THE REMOTE IS LOADED INSIDE A HOST
//    The host's _app.tsx already wraps the entire tree in <Provider>.
//    Wrapping the remote AGAIN inside its exposed component would
//    create nested providers. Both providers point to the SAME store
//    (singleton), so you do not get two stores — but React still emits
//    a warning about nested context providers.
//
//    The pattern: only wrap the remote's standalone _app.tsx with
//    ClientReduxProvider. Components exposed via Module Federation
//    (BeautyCare, ProductDetailPage, FAQ) MUST NOT wrap their
//    children in <Provider>. They rely on the host's provider
//    being the closest ancestor.
//
// Wrong:
//   export default function BeautyCare() {
//     return (
//       <ClientReduxProvider>     // <- DO NOT wrap exposed components
//         <ProductGrid />
//       </ClientReduxProvider>
//     );
//   }
//
// Right:
//   export default function BeautyCare() {
//     return <ProductGrid />;     // <- The host's <Provider> is already up the tree
//   }

The remote's ClientReduxProvider is used only when the remote runs standalone for local development (cd apps/Products && next dev -p 3001). When the remote is loaded inside the host as a federated module, the host's Provider is already wrapping the entire tree — wrapping again would create nested Providers (harmless because both point to the same singleton, but React emits a warning).

The rule is simple: only the standalone _app.tsx of a remote uses ClientReduxProvider. Every component exposed via Module Federation (BeautyCare, ProductDetailPage, FAQ, Login, OTPVerify) renders without its own Provider and relies on the host's Provider being the closest ancestor.

SSR Hydration with Federated Redux

SSR plus Redux is normally well-trodden territory — Next.js documents the per-request store pattern, and react-redux 9.x exports first-class hooks for it. SSR plus federated Redux is a different story because Module Federation containers cannot run on the server.

SSR + federated store hydration trap
# Why SSR + Federated Redux is a Hydration Trap
# ──────────────────────────────────────────────
#
# Three things need to be true for SSR + Redux to work cleanly:
#   1. The store exists during the server render.
#   2. The server-rendered HTML matches what the client renders.
#   3. The store does NOT leak between requests.
#
# In a Next.js MFE, problem (1) collides with Module Federation:
#
#   A. Module Federation containers attach to window.
#      The federated @myapp/store cannot initialize on the server
#      because window does not exist during SSR.
#
#   B. If you import @myapp/store at the module level on the
#      server, Next.js's server build crashes:
#        ReferenceError: window is not defined
#        at packages/store/index.js:4
#
#   C. If you create a server-only store using makeServerStore(),
#      you now have TWO stores: one server-only, one client-only.
#      The server pre-populates state, sends HTML, then the client
#      mounts and creates a NEW empty store — hydration mismatch.
#
# The chosen pattern: skip SSR for federated state entirely.
#
#   1. ssr: false on every remote import in the host
#   2. ssr: false on the ClientReduxProvider wrapper itself
#   3. The first paint is plain HTML with no user-specific state
#   4. The client takes over, mounts the Provider, and federates
#      the store — every remote receives the same singleton
#
# What you give up:
#   - Initial HTML cannot show 'Welcome back, John'
#   - Cart count badge is empty for the first ~200ms
#
# What you keep:
#   - Zero hydration mismatches
#   - SSR continues to work for everything that does NOT depend on
#     federated state (product titles, FAQ content, marketing copy)
#   - One store, one source of truth — across React + Next.js remotes

The chosen pattern in this codebase: skip SSR for federated state entirely. The first paint is a plain HTML shell with no user-specific content. The client takes over, mounts the Provider, federates the store, and rerenders the user-specific UI within ~200ms. What you give up is "Welcome back, John" on the very first paint. What you keep is zero hydration mismatches and one source of truth across React and Next.js remotes.

For the broader SSR-vs-CSR trade-off, the Next.js SSR vs CSR article covers the rendering decision in detail. For background on the React-Redux Provider model, the official react-redux docs (opens in a new tab) explain the context propagation — once the federated store is in place, the rest is the standard react-redux mental model.

Debugging Tip — Expose the Store on window (Dev Only)

The single most useful debugging trick when verifying federation is to expose the store on window in development. Combined with the Redux DevTools browser extension (opens in a new tab), you can confirm at a glance that exactly one store exists, regardless of how many remotes are mounted.

apps/Main/components/ClientReduxProvider.tsx (dev-only addition)
// apps/Main/components/ClientReduxProvider.tsx — Optional: dev-only tooling
'use client';
import { ReactNode } from 'react';
import { Provider, store } from '@myapp/store';

if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
  // Expose the store on window so you can inspect it from the console.
  // This is safe ONLY in development. In production, leaving the store
  // on window leaks user state to any third-party script.
  (window as any).__myapp_store = store;
}

interface ClientReduxProviderProps {
  children: ReactNode;
}

export default function ClientReduxProvider({ children }: ClientReduxProviderProps) {
  return (
    <Provider store={store}>
      {children}
    </Provider>
  );
}

// In the browser DevTools console:
//   __myapp_store.getState()                    // dump full state
//   __myapp_store.getState().user.isLoggedIn    // check auth flag
//   __myapp_store.dispatch({ type: 'user/clearUser' })  // log out from console
//
// Combined with the Redux DevTools browser extension, this is the
// single most useful debug tool when verifying that a federated
// store is working — both Auth (React) and Products (Next.js)
// dispatches show up in the same DevTools instance.

Then in the DevTools console:

__myapp_store.getState()                            // Dump full state
__myapp_store.getState().user.isLoggedIn            // Verify auth flag
__myapp_store.dispatch({ type: 'user/clearUser' })  // Force logout from console

If you mount the Auth remote and the Products remote, then run __myapp_store.getState().cart.items.length after adding to cart from /products, you should see the count update — even though Auth never participated in the cart action. That round-trip is the federation working.

Singleton Safety Net — strictVersion in Action

The strictVersion: true flag is the single most important safety mechanism for an in-house store package. With it, Module Federation refuses to load a remote that ships a different version than the host requires.

SituationstrictVersion offstrictVersion on
All remotes pinned to 1.0.0WorksWorks
One remote bumped to 1.1.0 (slice rename)Other remotes silently get 1.0.0; renamed slice crashes selectorsBuild error: Unsatisfied version 1.1.0 from Products of shared singleton module @myapp/store
Two remotes deployed at different timesFirst-loaded version wins; reads against the other version are undefinedBrowser refuses to mount the second remote until versions align
Forgotten version bump in one workspaceProduction silently runs mixed versions; bugs surface randomlyCI fails before deploy; mismatch caught early

The behavior is intentional. A federated store is a contract between the host and every remote. Half-migrated state is worse than a build failure — the strictVersion failure mode forces every workspace to bump together.

Common Gotchas When Federating a Redux Store

After running this pattern in production for months, seven specific gotchas account for the vast majority of incidents. Every one of them is a deviation from the singleton contract — either a missing singleton: true, a missing strictVersion: true, or an import that bypasses @myapp/store.

Federated Redux gotchas
# Federated Redux Store — Common Gotchas
# ───────────────────────────────────────
#
# 1. Two stores at runtime
#    SYMPTOM: Auth dispatches setIsLoggedIn(true). Products's
#             useSelector still returns false.
#    CAUSE:  A remote forgot to declare '@myapp/store' as singleton,
#            or imported from 'react-redux' directly instead of via
#            '@myapp/store', creating a fresh store instance.
#    FIX:    Audit every shared block. Every remote must list:
#              '@myapp/store':   { singleton: true, strictVersion: true, requiredVersion: '1.0.0' }
#              'react-redux':    { singleton: true, requiredVersion: false, eager: false }
#
# 2. Version mismatch refusal
#    SYMPTOM: Build fails with:
#             'Unsatisfied version 1.0.0 from Main of shared singleton module @myapp/store'
#    CAUSE:  One remote bumped @myapp/store to 1.1.0 and the host
#            still requires 1.0.0 (or vice versa).
#    FIX:    All remotes + host must declare the SAME requiredVersion.
#            Bump everywhere or roll back everywhere — strictVersion
#            does not allow drift.
#
# 3. window is not defined during build
#    SYMPTOM: Server build crashes inside packages/store at module load.
#    CAUSE:  An import chain pulled '@myapp/store' into a server-rendered
#            page. The store package references react-redux, which on
#            some versions touches window.
#    FIX:    Always import the store via a dynamic component with
#            ssr: false. Never import from '@myapp/store' at the top
#            of a page that runs on the server.
#
# 4. Hydration mismatch when reading user state
#    SYMPTOM: React error #418 — 'Hydration failed because the
#             initial UI does not match what was rendered on the server.'
#    CAUSE:  Server rendered 'Login' button (no store on server).
#            Client mounted the federated store and re-rendered
#            'Account' link (user is logged in).
#    FIX:    Render a loading shell on the server and switch to the
#            user-specific UI only after the client has mounted.
#            Pattern: const [mounted, setMounted] = useState(false);
#                     useEffect(() => setMounted(true), []);
#                     if (!mounted) return <Skeleton />;
#
# 5. Multiple Redux DevTools instances
#    SYMPTOM: Browser shows two stores in Redux DevTools, one per remote.
#    CAUSE:  Two physical store instances at runtime — same root cause
#            as Gotcha #1.
#    FIX:    Same as #1. When the singleton is correctly negotiated,
#            DevTools shows ONE store regardless of how many remotes
#            are mounted.
#
# 6. Direct import of 'react-redux' in a remote
#    SYMPTOM: Works locally, breaks in production with random
#             'undefined is not a function' from useSelector.
#    CAUSE:  The remote bundled its own copy of react-redux because
#            it imported from 'react-redux' instead of going through
#            '@myapp/store'. The bundled copy and the federated copy
#            disagree on internal state.
#    FIX:    Always: import { useSelector, useDispatch } from '@myapp/store';
#            Never:  import { useSelector } from 'react-redux';
#
# 7. Persisted state between dev hot reloads
#    SYMPTOM: Dev: log in → edit code → HMR reload → still logged in.
#    CAUSE:  Module Federation HMR keeps the store alive across reloads
#            since it lives in the host's module graph.
#    FIX:    Not a real bug. If you NEED a fresh store for a test,
#            run __myapp_store.dispatch({ type: 'RESET_ALL' }) after
#            adding a root reducer that handles RESET_ALL.

If you only remember one debugging step, it is this: open Redux DevTools and count the stores. If there is more than one, the singleton negotiation has failed and the rest of your investigation is a waste of time until the shared blocks are fixed.

Federated Store vs Per-Remote State — Decision Guide

Not every piece of state belongs in the federated store. Some state is local to a single remote and forcing it into the global store creates coupling that defeats the point of micro frontends.

State TypeWhere it livesWhy
Authentication (user.at, user.isLoggedIn)Federated storeEvery remote needs to know if the user is logged in
Cart state (cart.items, cart.totalItems)Federated storeMultiple remotes (Cart, Account, Header) read it
Support ticket list (tickets)Federated storeCross-remote — Support creates, Account displays
Form input state (e.g., login form fields)Local useStateBelongs to one remote, never read elsewhere
Modal open/close, accordion stateLocal useStatePure UI state
Per-page filters, sort orderLocal useReducer or URL search paramsSpecific to one route
Server-cached query dataTanStack Query / SWRHas its own caching layer; not Redux's job
WebSocket connection statusLocal useState in the WebSocket-owning remoteOnly the chat remote cares

The general rule: if exactly one remote ever reads or writes the state, it does not belong in the federated store. Local state stays local.

What's Next

You now have a single Redux store that is genuinely shared across React and Next.js remotes — the federated @myapp/store package, the ClientReduxProvider wrapper, the matching shared blocks across host and remotes, the runtime negotiation flow, the SSR hydration pattern, and the seven gotchas that account for almost every "state is not propagating" bug. The next article covers image optimization in Next.js micro frontends — the remotePatterns configuration, AVIF and WebP formats, device sizes, cache TTL, and the enableImageLoaderFix extra option that makes next/image work correctly when a Next.js host loads remotes from different domains.

← Back to Mixing React + Next.js Micro Frontends

Continue to Image Optimization in Next.js Micro Frontend →


Frequently Asked Questions

How does a single Redux store stay shared across React and Next.js remotes?

Module Federation negotiates shared dependencies as singletons when both ModuleFederationPlugin and NextFederationPlugin declare the same package with singleton: true. The store package (e.g., @myapp/store) is declared as a singleton with strictVersion: true and requiredVersion: '1.0.0' in the host AND every remote. The host creates the store once inside a ClientReduxProvider component. When a remote imports useSelector or useDispatch from @myapp/store, Module Federation's runtime says the store package is already loaded and returns the host's instance instead of building a new one. Both React remotes (built with ModuleFederationPlugin) and Next.js remotes (built with NextFederationPlugin) receive the SAME store reference, so dispatch in one remote triggers re-renders in every other remote consuming the same slice.

Why must I use ssr: false on the ClientReduxProvider wrapper?

Module Federation containers attach to the window object during runtime initialization. The federated @myapp/store can only register on the client, not during Next.js's server render. If _app.tsx imports the Provider directly at the top of the file, Next.js's server build attempts to run packages/store/index.js on the server and crashes with ReferenceError: window is not defined. Wrapping the Provider in a separate ClientReduxProvider component and importing it via next/dynamic with ssr: false ensures Next.js never tries to render the federated store on the server. The first paint is plain HTML without user-specific state, and the client takes over to mount the Provider once the browser has loaded the federated container.

Why does each remote also have its own ClientReduxProvider file?

Each remote keeps a ClientReduxProvider file for standalone development — when you run a remote independently with next dev or webpack-dev-server, there is no host to provide the Redux Provider. The remote's pages/_app.tsx wraps itself with ClientReduxProvider so useSelector and useDispatch still work in isolation. When the same remote is loaded inside the production host, it does NOT wrap its exposed components in <Provider> — those components rely on the host's provider being the closest ancestor in the React tree. Both providers point to the same federated store singleton, so even nested usage works, but the convention is to wrap only the standalone _app.tsx and leave exposed components free of <Provider>.

What happens if I bump @myapp/store from 1.0.0 to 1.1.0 in only one remote?

Module Federation's strictVersion: true with requiredVersion: '1.0.0' fails the runtime federation check. The browser console reports Unsatisfied version 1.1.0 from Products of shared singleton module @myapp/store (required =1.0.0 from Main). The remote refuses to load and the page shows a federation error. This refusal is the safety guarantee — without strictVersion, Module Federation would silently choose one version, and remotes built against the other version would crash on slice shape mismatches. To upgrade the store, bump @myapp/store to 1.1.0 in every workspace, rebuild every remote, and deploy them together. There is no half-migrated state where some remotes use 1.0.0 and others use 1.1.0.

Can I use server-side rendering for a page that reads from the federated Redux store?

Not directly. The federated @myapp/store only initializes on the client because Module Federation containers attach to the window. The recommended pattern is to skip SSR for federated state entirely — set ssr: false on the ClientReduxProvider and on every remote import. The server renders a plain HTML shell with no user-specific data, and the client mounts the Provider once the federated container loads. SSR continues to work for everything that does NOT depend on federated state — product titles, FAQ content, marketing copy, blog posts. For the small cases where you really need server-side user data (e.g., personalized SSR), use makeServerStore() to build a fresh per-request store, populate it from cookies on the server, and serialize the state into the HTML response — but accept that your hydration code now has to reconcile the server's per-request store with the client's federated singleton.

Should remotes import useSelector from react-redux or from @myapp/store?

Always import from @myapp/store. The store package re-exports useSelector, useDispatch, and Provider from react-redux, plus typed wrappers useAppSelector and useAppDispatch. Importing through @myapp/store guarantees that Module Federation's singleton negotiation routes the call to the host's react-redux instance. If a remote imports react-redux directly, webpack might bundle a separate copy at build time, and the bundled copy disagrees with the federated copy on internal state — leading to subtle production bugs where useSelector returns stale values or undefined. Centralizing all Redux primitives behind @myapp/store also lets you swap react-redux for a wrapper or upgrade major versions without touching every remote.

How do I debug a federated Redux store when state changes are not propagating between remotes?

Three checks in order. First, install the Redux DevTools browser extension (opens in a new tab) and verify that exactly one store appears in the Stores panel — multiple stores means singleton negotiation failed and you have one store per remote. Second, expose the store on window in development (window.__myapp_store = store) and run __myapp_store.getState() from any page; both Auth and Products should mutate the same object. Third, audit the shared block in every webpack.config.js and next.config.js — every remote must declare @myapp/store, react, react-dom, react-redux, and @reduxjs/toolkit as singletons with the exact same requiredVersion. The most common bug is one remote forgetting react-redux: { singleton: true } in its shared block, which silently bundles its own react-redux and creates a second store instance even though @myapp/store is correctly federated.