core icon indicating copy to clipboard operation
core copied to clipboard

Next.js 15 Pages Router: useRouter Breaks When Declaring the Plugin

Open danivmr opened this issue 8 months ago • 2 comments

Describe the bug

I am using Next.js 15 with the pages directory. When I declare the NextFederationPlugin in next.config.mjs, the useRouter hook from next/router throws the following error:

Error: NextRouter was not mounted.
Link: https://nextjs.org/docs/messages/next-router-not-mounted

However, when I remove the plugin, useRouter works as expected. Since my project is using the pages router, next/router should be functioning correctly.

Steps to Reproduce

  1. Set up a Next.js 15 project with the pages directory.
  2. Install @module-federation/nextjs-mf.
  3. Configure next.config.mjs to use NextFederationPlugin.
  4. Try to use useRouter inside a component.
  5. Observe the error message about NextRouter not being mounted.
  6. Remove the plugin and notice that useRouter works again.

Expected Behavior

  • useRouter should work even when the NextFederationPlugin is added to next.config.mjs.

Actual Behavior

  • useRouter throws an error when NextFederationPlugin is declared.

Environment

Package.json (dependencies)

{
  "dependencies": {
    "@module-federation/nextjs-mf": "^8.8.21",
    "next": "15.2.3",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "webpack": "^5.98.0"
  }
}

Next.js Config (next.config.mjs)

import { NextFederationPlugin } from '@module-federation/nextjs-mf';

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  webpack(config, options) {
    config.plugins.push(
      new NextFederationPlugin({
        name: 'mfe1',
        filename: 'static/chunks/remoteEntry.js',
        remotes: {
          mfe2: `http://localhost:3001/static/${options.isServer ? 'ssr' : 'chunks'}/remoteEntry.js`,
        },
        shared: {},
        extraOptions: {
          exposePages: true,
          enableImageLoaderFix: true,
          enableUrlLoaderFix: true,
        },
      })
    );
    return config;
  },
};
export default nextConfig;

Additional Notes

  • This issue occurs only when NextFederationPlugin is present.
  • Removing the plugin restores the expected behavior of useRouter.
  • This might be a conflict between Module Federation and Next.js's internal routing system.

Would appreciate any insights on whether this is a bug or if there's a missing configuration. Thanks! 🚀

Reproduction

https://codesandbox.io/p/github/dani0f/nextjbug/main?import=true

Used Package Manager

npm

System Info

System:
    OS: Linux 6.1 Ubuntu 20.04.6 LTS (Focal Fossa)
    CPU: (2) x64 AMD EPYC
    Memory: 3.16 GB / 4.14 GB
    Container: Yes
    Shell: 5.0.17 - /bin/bash
  Binaries:
    Node: 20.12.1 - /home/codespace/nvm/current/bin/node
    Yarn: 1.22.19 - /usr/bin/yarn
    npm: 10.5.0 - /home/codespace/nvm/current/bin/npm
    pnpm: 8.15.6 - /home/codespace/nvm/current/bin/pnpm

Validations

danivmr avatar Mar 25 '25 19:03 danivmr

@dani0f I'm having the same problem, which at the time is preventing us from patching the last vulnerability. Please share whatever workaround you could find!

ciandt-crodrigues avatar Mar 26 '25 16:03 ciandt-crodrigues

@dani0f Same issue I'm also facing

ayush-nowoptics avatar Mar 27 '25 13:03 ayush-nowoptics

try sharing next/compat/router.

next/compat/router and see if it works? it looks like they might have changed something in v15

ScriptedAlchemy avatar Mar 28 '25 05:03 ScriptedAlchemy

Changing to use next/compat/router didn't really worked for me as it changes the behavior too much, and most of the time returns null instead of the router unfortunately. Adding it to the share didn't change anything too.

The required file that does not seems to be not included as shared is next/dist/shared/lib/router-context.shared-runtime but I was unable to make it work, probably for my lack of understanding.

@ScriptedAlchemy would you have some suggestions on what else I could try?

They definitely change it it stopped working from 15.2.X

ciandt-crodrigues avatar Mar 29 '25 21:03 ciandt-crodrigues

I'll probably have to contact vercel about this

ScriptedAlchemy avatar Mar 31 '25 06:03 ScriptedAlchemy

I conducted an experiment comparing Next.js versions 14 and 15 in a Module Federation setup. The results show that the router only works correctly when passed as a prop to the federated module. Other approaches either result in errors or return null.

Experiment Results

The table below shows the behavior of the router inside the remote module under different host and remote version combinations.

Scenario next/router next/compat/router Prop
Host 14 with Remote 14 Works No error, but router is null Works
Host 14 with Remote 15 Works No error, but router is null Works
Host 15 with Remote 14 Error No error, but router is null Works
Host 15 with Remote 15 Error No error, but router is null Works

Solution

The only way I found to use the router in a federated module is to:

  1. Import it from next/router/compat in the host.
  2. Pass it as a prop to the remote module.
  3. Use it inside the remote component via props.

danivmr avatar Mar 31 '25 18:03 danivmr

I wonder if it's due to sharing issues. From what chunk is the next router fetched? The router should load from the host only. Is the router loading from more than the host port, or any other port? The runtime plugin checks for the next router and then forwards the reference to its own React from its internal scopes.

ScriptedAlchemy avatar Apr 04 '25 11:04 ScriptedAlchemy

@ScriptedAlchemy It does seems to be related to sharing. Importing the next/router works on the host if I remove it from the list of shared dependencies like this:

Image

but unfortunately I was unable to make sharing it using module federation. the props things should work but It is not feasible on my codebase

ciandt-crodrigues avatar Apr 06 '25 22:04 ciandt-crodrigues

what if you share it as the next/compat/router?

ScriptedAlchemy avatar Apr 09 '25 08:04 ScriptedAlchemy

  shared: {
    'next/compat/router': {
      requiredVersion: false,
      singleton: true,
      import: undefined
    }
  }

causes the router returned by

import { useRouter } from 'next/compat/router';

to be null on the host app.

shared: {
  'next/router': {
    requiredVersion: false,
    singleton: true,
    import: 'next/compat/router'
  }
}

also causes the router returned by

import { useRouter } from 'next/router';

to be null on the host app.

dhalbrook avatar Apr 11 '25 15:04 dhalbrook

@ScriptedAlchemy It does seems to be related to sharing. Importing the next/router works on the host if I remove it from the list of shared dependencies like this:

Image

but unfortunately I was unable to make sharing it using module federation. the props things should work but It is not feasible on my codebase

This was the only thing that helped me fix the issue with the router on the host so i was able to use it there, but it still gives some issues on the loaded mfe.

calinvasileandrei avatar Apr 24 '25 13:04 calinvasileandrei

Having the same issue.

The config from ciandt-crodrigues helped to resolve the issue on the host app (no errors, redirecting works ok).

anna-akhmet avatar Apr 29 '25 13:04 anna-akhmet

@ScriptedAlchemy It does seems to be related to sharing. Importing the next/router works on the host if I remove it from the list of shared dependencies like this: Image but unfortunately I was unable to make sharing it using module federation. the props things should work but It is not feasible on my codebase

This was the only thing that helped me fix the issue with the router on the host so i was able to use it there, but it still gives some issues on the loaded mfe.

Dont know if this belongs in this issue but using "next/head" also appears to have stopped working. For example setting browser titles with the <Head> component no longer sets the title.

Also removing "next/head" from the defaultShared dependencies, like in your example, seems to be working as a workaround for the time being.

MikeMbHS avatar May 08 '25 08:05 MikeMbHS

@ScriptedAlchemy Sorry to bother, but just to be clear. At the moment, no one is working on this issue, and there is no plan to have someone working on this due to NextJS being in maintenance mode?

I've shared a workaround here, but unfortunately it does not work for me, tried my best to troubleshoot and help but I don't know how does module federation works with the shared dependencies and I find myself unable to proceed by myself. I would really love to help but the inner works on how this works does not seems clear to me just by looking at the code.

ciandt-crodrigues avatar May 26 '25 14:05 ciandt-crodrigues

Support for Nextjs as a whole is actively being worked on right now. Sometimes we're a bit behind on issues. apologies.

zackarychapple avatar May 27 '25 03:05 zackarychapple

Image

Okay so theres some bug / backward compat i didnt factor in when i designed layered sharing - i need to test it further but 0.0.0-next-20250612091126 seems to make next.js 15 work without react hook error etc.

For next/router, the reason you get router not mounted or null is becasue router is strictly layer based, and default one in my share config is unlayered and i did not change share config in the canary.

but you can add this to your own config and seems to work at a glance

ScriptedAlchemy avatar Jun 12 '25 09:06 ScriptedAlchemy

@ScriptedAlchemy, thanks for the reply!

I've got a different error with that:

Image

"next": "^15.3.3" "@module-federation/nextjs-mf": "0.0.0-next-20250612091126" With and without the shared section for next/compat/router

ciandt-crodrigues avatar Jun 12 '25 13:06 ciandt-crodrigues

Look like server issue, server and client have different layers.

Something like this may work.

     layer: config.isServer ? 'pages-dir-node' : 'pages-dir-browser',
     issuerLayer: config.isServer ? 'pages-dir-node' : 'pages-dir-browser'

I also pushed a canary of v9 which may be a little better. It has some of the share configs where the layer stuff is baked in, however not all.

you can try v9 canary 0.0.0-next-20250616235047 this seems to work

ScriptedAlchemy avatar Jun 17 '25 00:06 ScriptedAlchemy

Also note: When you expose or share your own packages, if they depend on react - this causes additional issues. Because your shared module is not specifying a layer, it falls through or falls back to a different variant of react in the bundle. Basically it falls through to 'unlayered' react, while next itself actually uses 3 copies of react, 1 on each layer.

In this v9, i have updated federation core plugin where i adjust how these layered shared are dealt with.

I had chatgpt help detail the underlaying problem and mechanics a little, as its complex - but would be helpful to understand whats going on.

Next.js 15 Pages Router Layer Conflicts & Module Federation Sharing

Thanks for reporting this issue! This is a known problem that relates to how Module Federation handles layered shared modules in Next.js, particularly around React sharing between different execution contexts.

Root Cause: Layer Key Mismatches & Share Scope Resolution

The useRouter issue you're experiencing is a symptom of a deeper problem with how Module Federation's sharing system works with Next.js layers. Here's what's actually happening:

How Module Federation Sharing Works in Next.js (with Layers)

Overview

Module Federation's sharing system in Next.js is built around the concept of layered module resolution. This is especially important in Next.js 13+ (and v15), where different parts of the app (pages, app, server, client) may run in different "layers" and require different versions or instances of shared modules like React.

How Sharing is Configured

  • Exposed modules are always unlayered. When you expose a module, it does not have a layer or issuerLayer by default.
  • Shared modules are only layered by default if the user does not specify a different layer field. If you provide a layer in your shared config, that will be used; otherwise, the default layer is applied.
  • The issuerLayer is used internally to match shared requests to shared modules. When a module is requested, the system tries to match the request's issuerLayer to the shared module's layer.

How Sharing is Matched

The core matching logic is in resolveMatchedConfigs.ts:

  • Key Generation:
    Each shared config is stored in a map using a composite key:
    • If issuerLayer is set, the key is (<issuerLayer>)<request>, e.g. (pages-dir-browser)react
    • If issuerLayer is not set, the key is just <request>, e.g. react
  • Three Maps:
    • resolved: For direct file paths (absolute or relative)
    • unresolved: For module names (like react, next/router)
    • prefixed: For module prefixes (like next/dist/shared/)

When a module is requested, the plugin tries to match the request using the composite key. If the request comes from a specific layer, it looks for a layered key. If not, it looks for an unlayered key.

Why Layering Matters

  • Layered Sharing:
    Next.js creates layered shared configs for core modules (like React, Next.js internals) so that each execution context (pages, app, server, RSC) can have its own isolated instance if needed.
  • Unlayered Sharing:
    If you expose or share your own packages (like a design system, UI library, or utility) and do not specify a layer, those modules will be stored and matched as unlayered. This can cause them to fall through to a different instance of a shared dependency (like React) than what Next.js uses in its own layers.

Example: The Layered vs. Unlayered Key Problem

// Exposed module (always unlayered)
{
  request: 'my-exposed-component',
  shareKey: 'my-exposed-component',
  layer: undefined,
  issuerLayer: undefined // Always unlayered
}

// Shared module (layered by default unless user specifies otherwise)
{
  request: 'react',
  shareKey: 'react',
  layer: 'pages-dir-browser',
  issuerLayer: 'pages-dir-browser' // Used for matching
}
  • If a federated module requests 'react' without an issuerLayer, it will look for the unlayered key ('react').
  • If Next.js requests 'react' from a specific layer, it will look for the layered key ('(pages-dir-browser)react').
  • If your shared module (like antd) requests 'react' and is not layered, it may get a different instance than the rest of the app, leading to context mismatches.

What to Watch Out For

  • Exposed and shared modules without an issuerLayer will always match the unlayered key. This is the root cause of many context and version issues in Next.js 15 with Module Federation v8.
  • If you share your own packages (like antd, design systems, or utilities), be aware that their dependencies (like React) may fall through to the unlayered version unless you explicitly layer them.
  • Incorrect or missing layer configuration can cause silent failures, context mismatches, and hard-to-debug bugs.

Background Context

  • The v9 plugin update introduces better handling for these cases by allowing issuerLayer: undefined to fall back to the correct shared instance, but you must still be careful with your own shared module configurations.
  • The test repo at https://github.com/ScriptedAlchemy/next-apps-ejected demonstrates these concepts in practice.

This background should help you understand why sharing sometimes "just works" and sometimes fails in subtle ways, especially as you add more shared modules or use third-party libraries in a federated Next.js setup.

Also

How i currently got it to work, where pages router app exposes something from like the shop app, and it works in the home app. I set react and next router so that they share with layer, but additionally without a issuerLayer.

issuerLayer is 'what layer is the module requesting react in' - so we have to know if its a RSC component asking for react or a use client or ssr etc, 'layer' is how we will create out consume shared FallbackModule to match.

To maintain a singleton for anything where one hasnt set this on their own shares, or when i expose a module. I have specified a shared module with a layer, but with no issuerLayer - so exposed modules and shared modules and default share scope will essentially point back to pages-dir-browser as and pages-dir-node.

This will be problematic for app dir, because user will have to specify the layer or the next plugin must automatically take unlayered shares and split them into 3 layers under the hood. The problem is, if some share module contains server only or use client and then the rsc loader (who is applied to all modules with the rsc layer or issuerLayer), will throw the build upon parsing a forbidden module.

and exposed modules as mentioned dont even have a config convention - so setting layer or issuerLayer would require a major change. But what i experimented with is makeing a directory for rsc and pages, and then have a loader test those paths and just apply layer: 'rsc' - which is what /pages/ and /app/ are doing currently inside next.

ScriptedAlchemy avatar Jun 17 '25 01:06 ScriptedAlchemy

TLDR: use follow this implementation or use the version in this repo to test it out. https://github.com/ScriptedAlchemy/next-apps-ejected Ill make edits and install new canary into it to test out v15 and v9

ScriptedAlchemy avatar Jun 18 '25 05:06 ScriptedAlchemy

@ScriptedAlchemy

thanks for all the hard work! We managed to apply this in our project and seems to work with latest NextJS and React versions 😄 I was wondering, is there a plan in a short-term to release a more "stable" version rather than the canary one 0.0.0-next-20250616235047 as you mentioned before? I've read that one of the goals is to make the pages dir work as it was, and we were wondering if a new version is going to be released soon to make this update asap :) thanks in advance!

mariofpalb avatar Jul 07 '25 06:07 mariofpalb

@ScriptedAlchemy

thanks for all the hard work! We managed to apply this in our project and it seems to work with the latest NextJS and React versions 😄 I was wondering, is there a plan in a short-term to release a more "stable" version rather than the canary one 0.0.0-next-20250616235047 as you mentioned before? I've read that one of the goals is to make the pages dir work as it was, and we were wondering if a new version is going to be released soon to make this update asap :) thanks in advance!

Its being worked on. Follow my prs around app-router-share-filter and share-filter branches. Its a large update. I've been trying to get the tests to pass with both next 14 and next 15. Not easy since v15 is significantly different. On top of that, interoperability between 14 and 15 when sharing code is also unknown.

Once CI is green. I'll try to get some of this pushed to at least a beta or alpha tag on npm.

This is a very big change to both next plugin and module federation.

ScriptedAlchemy avatar Jul 07 '25 08:07 ScriptedAlchemy

@ScriptedAlchemy Hi when can we expect to have a stable version for this issue. Canary version seems to be working but getting few css issues. Please try to release a stable version.

Namrata-m-aurya avatar Aug 01 '25 10:08 Namrata-m-aurya

@ScriptedAlchemy Hi when can we expect to have a stable version for this issue. Canary version seems to be working but getting few css issues. Please try to release a stable version.

Contribute if you want it faster.

ScriptedAlchemy avatar Aug 02 '25 21:08 ScriptedAlchemy

@ScriptedAlchemy Thanks for sharing that repo link for testing out new MF! I was curious to test out the exposed-pages example since that is how we leverage MF (using the build plugin + loadRemote + Pages Router) and when trying to load that page I see this error when running pnpm dev

Image

Not sure if it is expected or not?

This pattern is working well for us on Next 14 + MF v8, fwiw.

thescientist13 avatar Oct 29 '25 16:10 thescientist13