Proof of concept for consuming a third-party React component at runtime via Webpack 5 Module Federation, with the same USWDS branding overlay used in POC 1 applied to the federated surface.
This is one of four repos in the POC set:
| Repo | Role | Port |
|---|---|---|
shell-poc1 |
CSS-overlay shell (POC 1) | 3000 |
third-party-poc1 |
CSS-overlay target (POC 1) | 3001 |
shell-poc2 (this) |
Module Federation host | 3010 |
third-party-poc2 |
Module Federation remote | 3011 |
The original Forum One starter docs are at README.nextjs.md
and README.project.md.
The spec said to use @module-federation/nextjs-mf. That package declares
peer dependency:
next@"^12 || ^13 || ^14 || ^15"
It does not support Next.js 16. Attempting to install against the
starter (Next 16.2.3) produces an ERESOLVE error. There is no Next.js 16
adapter released as of the date of this POC.
This POC uses @module-federation/enhanced directly with custom webpack
config, which does install. With it:
- ✅ The remote produces a valid
remoteEntry.jsathttp://localhost:3011/_next/static/chunks/remoteEntry.js. - ✅ The host's webpack config resolves the federated import at build time.
- ✅ The host serves
/toolwith the dynamic import + error boundary client component. - ❌ At browser runtime, the federation share runtime throws
loadShareSyncerrors trying to initialize the shared React copy. The symptom isInvalid hook call/Cannot read properties of null (reading 'useContext')— the classic two-Reacts problem when MF shares fail to deduplicate.
The root cause is the lack of a Next.js-specific federation wrapper for
the App Router that knows how to set up async boundaries the way the
runtime expects. The legacy nextjs-mf wrapper handled this for Pages
Router; no equivalent exists yet for App Router + Next 16.
Recommended path forward (for the real engagement):
| Option | Implication |
|---|---|
Both teams agree to Pages Router on Next ≤15 + @module-federation/nextjs-mf |
Federation works today, faithful to the spec. Pages Router is in long-term maintenance mode; App Router is the strategic direction. |
Wait for App Router support in @module-federation/nextjs-mf or an equivalent wrapper |
No timeline guarantees. |
| Drop federation, use POC 1's CSS overlay + iframes for tool surfaces | Falls back to a working, lower-coupling approach. |
The recommendation lives in the parent plan, not this README. What this README documents is the technical reality the proposal is built on.
Everything except the actual cross-bundle React handshake is wired up:
next.config.js—ModuleFederationPluginhost configuration. Federation runs client-only (server bundle aliasesthirdparty/Toolto a no-op stub atlib/remote-stub.tsx). This shape is what you'd ship if the runtime path worked.app/tool/page.tsx— server component that renders the federated tool inside the shell's USWDS chrome.app/tool/RemoteTool.tsx— client component withnext/dynamic({ ssr: false })import ofthirdparty/Tool, wrapped in an error boundary that surfaces a "Tool unavailable" fallback when the remote is unreachable or the runtime fails.remote-modules.d.ts— TypeScript declaration soimport 'thirdparty/Tool'typechecks without a real filesystem resolution.public/brand/overrides.css— same USWDS DTCG-generated overrides as POC 1. Demonstrates that the CSS-overlay branding approach from POC 1 works identically here: the federated component would cascade against these tokens for free if it rendered.
Because the federated component mounts inside the host's DOM, it inherits
the host's :root custom properties through normal CSS cascade. POC 1's
mechanism applies unchanged — no extra federation-aware branding hook is
needed. The <link rel="stylesheet" href="/brand/overrides.css"> in
app/layout.tsx covers both the shell chrome and any federated component
rendered inside it.
The federated component runs inside the host's origin (it's bundled into
client chunks served from :3010, not requested cross-origin per render).
That means cookies are the host's cookies — same origin-scope. The
remote's own API calls go to :3011 unless explicitly routed; that's
the real trust boundary the auth design must cross.
Three propagation patterns to choose from:
- React context — host puts a
Sessioninto context, federated component reads it. Tight coupling: the context shape becomes a contract that both teams pin. Simplest to wire; brittle to schema drift. - Token forwarding — host mints a short-lived JWT scoped to the
remote's API and passes it via prop or
Authorizationheader on the remote's fetches. Cleanest separation; requires both teams to agree on a token format (probably the same OIDC ID/access token). - Cookie-shared subdomain — host sets the session cookie on
.example.gov; remote API attool.example.govreads it. Easiest if the deployment topology supports it.
Federal-context note: PIV/CAC and ICAM-based SSO add session-refresh requirements that token-forwarding (pattern 2) handles better than context-sharing — context can go stale silently, whereas an expired token returns a 401 from the API and surfaces the refresh need.
What the third party must commit to: a contract for context shape or token format. Without that agreement, federation works visually but auth breaks.
- Webpack-only. rspack/Turbopack support is still maturing — Next 16
defaults to Turbopack for dev (the Forum One starter explicitly passes
--webpackto avoid this, which is the right move). - Version-locking shared deps. Host and remote must agree on major
versions of React, ReactDOM, and Next. POC 2 declares
singleton: true, requiredVersion: false; production should pin to a specific version range. - SSR with App Router is the rough edge.
ssr: falseis the pragmatic POC choice. It costs SEO and time-to-paint on the federated route. - Failure modes when the remote is down. The error boundary in
RemoteTool.tsxhandles this — without it, a crashloop would surface as a blank tool route. - Deploy coupling. A breaking change in the remote's exposed interface breaks the host at runtime, not build time. Needs contract testing (e.g., schema-check the federated component's prop interface in CI).
# Terminal 1 — start the remote first so the host can load remoteEntry.js
cd ../third-party-poc2
npm install
npm run dev # http://localhost:3011
# Terminal 2 — start the host
cd ../shell-poc2
npm install
npm run dev # http://localhost:3010
Visit http://localhost:3010/tool and observe the browser console — the
finding above is reproducible.
render.yaml is committed. To deploy, push to GitHub, connect in Render,
accept the blueprint. Set NEXT_PUBLIC_REMOTE_URL in Render env to point
at the deployed third-party-poc2 URL's remoteEntry.js path.