Skip to content

Application-level SSR for bridge remotes (React & Vue): renderToString contract + hydration #4868

Description

@tonoizer

Clear and concise description of the problem

As a developer using Module Federation with the bridge (@module-federation/bridge-react / @module-federation/bridge-vue3), I want application-level remotes to be fully server-side renderable — not just client-side mounted — so that a host (an Express/Nitro custom server, Modern.js, Next, Nuxt, Astro, TanStack Start, …) can render a federated remote application to HTML on the server, ship it in the document, and hydrate it on the client with working, URL-synced routing.

Today createBridgeComponent / createRemoteAppComponent are CSR-only: a server-rendered host shows a loading placeholder where the remote should be, which hurts first paint, SEO, and no-JS rendering. Module Federation already supports loading remotes in Node — what's missing is an SSR contract at the bridge level.

I intend to submit a PR for this — the implementation is complete and CI-green on my fork, including a runnable demo and Playwright e2e suite. I'll link the draft PR here.

Suggested solution

Extend the existing bridge provider contract with an optional server entry point, keeping the current render/destroy API untouched:

  • Remote side: providers created by createBridgeComponent additionally expose renderToString(info) => Promise<{ html, state }> (React via react-dom/server, Vue via @vue/server-renderer — both lazy-loaded so CSR-only client bundles are unaffected).
  • Host side: a new renderRemoteAppToString({ loader, moduleName, basename, url, ... }) helper (in bridge-react/ssr and re-exported from bridge-vue3) calls the remote's renderToString through loadRemote on the server and returns html, serialized state, and optional assets.
  • Hydration: createRemoteAppComponent accepts ssrHtml/ssrState; when present the bridge hydrates (hydrateRoot / createSSRApp + mount(dom, true)) instead of client-rendering. State is transferred via a cycle-safe, </script>-escaped JSON script, with bridge metadata namespaced under __mfBridge so it can't collide with app props. SSR metadata such as requestHeaders is stripped and never reaches the client.
  • URL-driven routing: passing url + basename renders the remote through StaticRouter (react-router v5/v6/v7 shims) or vue-router server-side, and hydrates into BrowserRouter / web history — so in-remote navigation updates the address bar and deep links SSR the correct route. The existing memoryRoute mode is kept for embedded remotes.
  • Config: a new bridge.ssr: boolean option on ModuleFederationPlugin aliases router imports to the SSR-aware shims.

The PR includes unit tests per router version, a federated Rsbuild + Express demo (apps/bridge-ssr-demo) with React and Vue remotes, and a Playwright suite covering no-JS rendering, deep-link SSR, hydration interactivity, SPA navigation, and address-bar sync.

Known limitations (documented in the PR): the contract is renderToString-based (no streaming yet — the API shape leaves room for a later renderToStream), and react-router data-router loaders are not executed during bridge SSR (components render with empty loader data and fetch after hydration).

Alternative

  • Component-level federated SSR (current ecosystem answer, e.g. in Modern.js hosts): works well for federated components, but application-level bridge remotes (createBridgeComponent — an entire app with its own router) remain CSR-only even there. This proposal makes application-level modules themselves SSR-able at the bridge layer, so it benefits Modern.js hosts just as much as custom Node/Express/Nitro servers or other frameworks.
  • iframe or edge-side includes: avoids the hydration problem but gives up shared runtime, routing integration, and props.
  • Each team hand-rolls loadRemote + renderToString: possible today, but everyone re-solves state transfer, XSS-safe serialization, basename handling, and hydration mismatches — exactly the class of bugs the bridge exists to solve once.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions