# Modern.js + MF 2.0 spike report

**Date:** 2026-04-14
**Goal:** Validate that Modern.js 2.x + `@module-federation/modern-js` can produce both standalone SSR and CDN-static remote artifacts from a single source tree, and pin versions for 1A-2.

## Pinned version matrix

| Package | Version | Source |
|---|---|---|
| `@modern-js/app-tools` | `2.70.8` | pnpm-lock.yaml of scratch project |
| `@modern-js/runtime` | `2.70.8` | pnpm-lock.yaml of scratch project |
| `@modern-js/plugin-ssr` | N/A (SSR is built into `@modern-js/app-tools` in v2.x) | verified: no separate package exists in the dep tree |
| `@module-federation/modern-js` | `2.3.2` | pnpm-lock.yaml of scratch project |
| `@module-federation/enhanced` | `2.3.2` | pnpm-lock.yaml of scratch project |
| `react` | `18.3.1` | resolved from `^18.2.0` |
| `react-dom` | `18.3.1` | resolved from `^18.2.0` |
| `@types/react` | `18.3.28` | resolved |
| `@types/react-dom` | `18.3.7` | resolved |
| `typescript` | `5.5.4` | resolved from `~5.5.0` |
| `@rspack/core` (via Modern.js) | `1.7.11` | resolved transitively |
| `@rsbuild/core` (via Modern.js) | `1.7.3` | resolved transitively |

## Dual-build feasibility

- [x] Single `modern.config.ts` with `BUILD_TARGET` branching produces both targets -- **YES**
- [x] `dist/standalone/` contains Node SSR server -- **YES** (contains `bundles/` directory with `main.js`, `main-server-loaders.js`, and SSR-rendered chunk bundles)
- [x] `dist/remote/mf-manifest.json` contains all exposed modules -- **YES** (contains `./Hello` expose with asset paths)

All three criteria pass. The `BUILD_TARGET` env var read at config time cleanly switches between SSR and CSR-only modes.

### How it works

`modern.config.ts` reads `process.env.BUILD_TARGET` and branches:

```typescript
const isRemote = process.env.BUILD_TARGET === 'remote';

export default defineConfig({
  plugins: [appTools({ bundler: 'rspack' }), moduleFederationPlugin()],
  server: isRemote ? {} : { ssr: { mode: 'stream' } },
  output: {
    distPath: { root: isRemote ? 'dist/remote' : 'dist/standalone' },
  },
});
```

- `BUILD_TARGET=standalone` (or unset): SSR enabled via `server.ssr.mode: 'stream'`, outputs to `dist/standalone/`. Produces both client-side JS + Node SSR bundles in `bundles/`.
- `BUILD_TARGET=remote`: SSR disabled (empty `server` config), outputs to `dist/remote/`. Produces only client-side JS + MF artifacts. No `bundles/` directory.

Both targets get the MF plugin, so `mf-manifest.json` + `remoteEntry.js` + `@mf-types.zip` are emitted in both builds.

## Emitted mf-manifest.json shape

```json
{
  "id": "spike",
  "name": "spike",
  "metaData": {
    "name": "spike",
    "type": "app",
    "buildInfo": {
      "buildVersion": "1.0.0",
      "buildName": "modernjs-mf-spike"
    },
    "remoteEntry": {
      "name": "remoteEntry.js",
      "path": "",
      "type": "global"
    },
    "types": {
      "path": "",
      "name": "",
      "zip": "@mf-types.zip",
      "api": "@mf-types.d.ts"
    },
    "globalName": "spike",
    "pluginVersion": "2.3.2",
    "prefetchInterface": false,
    "publicPath": "/"
  },
  "shared": [
    {
      "id": "spike:react-dom",
      "name": "react-dom",
      "version": "18.3.1",
      "singleton": true,
      "requiredVersion": "^18.2.0",
      "assets": {
        "js": {
          "async": [],
          "sync": ["static/js/async/396.e11e8a41.js"]
        },
        "css": { "async": [], "sync": [] }
      },
      "fallback": ""
    },
    {
      "id": "spike:react",
      "name": "react",
      "version": "18.3.1",
      "singleton": true,
      "requiredVersion": "^18.2.0",
      "assets": {
        "js": {
          "async": [],
          "sync": ["static/js/async/75.30dce1d1.js"]
        },
        "css": { "async": [], "sync": [] }
      },
      "fallback": ""
    }
  ],
  "remotes": [],
  "exposes": [
    {
      "id": "spike:Hello",
      "name": "Hello",
      "assets": {
        "js": {
          "sync": ["static/js/async/__federation_expose_Hello.24756ba8.js"],
          "async": []
        },
        "css": { "sync": [], "async": [] }
      },
      "path": "./Hello"
    }
  ]
}
```

Key observations about the manifest shape:
- Top-level `id` and `name` match the `name` field in `module-federation.config.ts`.
- `metaData.remoteEntry.name` is the `filename` field from config.
- `metaData.pluginVersion` reflects `@module-federation/modern-js` version.
- `metaData.publicPath` defaults to `"/"` -- must be overridden for CDN deployment.
- `exposes[].path` uses the `./Hello` convention from config.
- `shared[]` entries include resolved `version` and `requiredVersion`.
- `@mf-types.zip` is auto-generated for TypeScript type sharing between remotes.

## Gotchas discovered

1. **`@module-federation/modern-js` is the correct package name, not `-v3`.** Both `@module-federation/modern-js` and `@module-federation/modern-js-v3` exist on npm at the same version (2.3.2), but the non-suffixed name is the primary package with the correct exports. The `-v3` variant appears to be an alias. Use `@module-federation/modern-js`.

2. **Modern.js 3.x (v3.1.3) is incompatible with `@module-federation/modern-js` v2.3.2.** Modern.js 3.x uses Rspack 2.0.0-rc.0, which has breaking API changes (`api.modifyWebpackConfig` removed, Rspack hook shape changes). The MF plugin crashes with `TypeError: api.modifyWebpackConfig is not a function` (SSR plugin) and `TypeError: Cannot read properties of undefined (reading 'tap')` (EmbedFederationRuntimePlugin). Use Modern.js 2.x (v2.70.8) which ships Rspack 1.7.11.

3. **`routes/layout.tsx` is mandatory.** Modern.js v2 requires a root layout component at `src/routes/layout.tsx`. Without it, the build fails with `Error: The root layout component is required`. The generated scaffold from `@modern-js/create` includes this automatically, but manual project setup must add it.

4. **SSR stream mode logs a misleading warning.** When SSR is enabled with `mode: 'stream'`, the build logs `splitChunks.chunks = async is not allowed with stream SSR mode, it will auto changed to "async"`. This is a no-op warning (it changes `async` to `async`, which is the same value) -- safe to ignore.

5. **`@modern-js/plugin-ssr` does not exist as a separate package.** In Modern.js 2.x, SSR support is built into `@modern-js/app-tools`. The design spec references `@modern-js/plugin-ssr` but this is not needed -- just set `server.ssr.mode` in `modern.config.ts`.

6. **`appTools({ bundler: 'rspack' })` is required.** Without the explicit `bundler: 'rspack'` option, Modern.js 2.x defaults to webpack, which would conflict with the MF plugin's Rspack-specific code paths.

7. **`publicPath` in the manifest defaults to `"/"`**. For CDN deployment, `output.assetPrefix` must be set in `modern.config.ts` to the CDN URL (e.g., `https://cdn.example.com/flights/`). This will propagate into `metaData.publicPath` in `mf-manifest.json`.

8. **Type generation (`@mf-types.zip`) happens automatically.** The MF plugin generates a type bundle at `dist/@mf-types.zip` containing TypeScript declarations for all exposed modules. This enables type-safe consumption in host apps without manual type sharing.

## Known incompatibilities

- `@module-federation/modern-js` v2.3.2 has a peer dependency on `react@"^16.3.0 || ^17.0.0 || ^18.0.0"`, which means React 19 is not officially supported. React 18.3.1 resolves cleanly. If the project later needs React 19, the MF plugin version must be updated or the peer dep overridden.
- Modern.js 3.x (v3.1.3, released with `@modern-js/create@3.1.3`) is not compatible with `@module-federation/modern-js` v2.3.2. The MF plugin team will need to release a v3-compatible version. For now, pin to Modern.js 2.70.8.

## Decision

**GO.** The dual-build approach works cleanly with Modern.js 2.70.8 + `@module-federation/modern-js` 2.3.2. A single `modern.config.ts` with `BUILD_TARGET` env var branching produces:
- `dist/standalone/`: Node SSR server + client bundle (stream mode)
- `dist/remote/`: CDN-static MF 2.0 remote with `mf-manifest.json`, `remoteEntry.js`, typed exports

No workarounds or hacks required. The version matrix is stable and the manifest shape is well-defined. Proceed with Tasks 2-10 of Phase 1A-2 using these pinned versions.
