
On one of our projects, we were building microfrontends, and at some point we wanted to add SSR. The reasons were the usual ones: better first paint, fewer layout shifts, real content for crawlers, less JS to load before something appears on screen. Setting it up turned out to be harder than I expected. There was no obvious out-of-box path that fit our setup, and most of the approaches I found either assumed a shared build or asked us to add new infrastructure on top of what we already had. That is what made me start sketching a small package. Something any team could drop in and get SSR for their microfrontend without rewriting either side. The result is @mf-toolkit/mf-ssr . The rest of this is about the approach behind it, since I think that is the interesting part. What I Wanted I started from a short list, taken straight from how I'd want to use such a thing: MF content on first paint. The remote's HTML should arrive inside the host's server response, not be fetched from the client after JS loads. No empty slot, no layout shift, real content in crawlers. No shared build, no central orchestrator. Each team builds and deploys their remote on their own schedule. The host should not need a special Node process that imports every remote into one bundle, and remote teams should not need to rewrite their bundler config to fit a central setup. Two paths for two setups, one host component. I wanted both scenarios covered. url mode for when the remote team runs their own server and wants to own SSR on their side (and possibly use a non-React framework). loader mode for when the remote only ships a static React bundle and the host server can do the SSR for it. The host code should look almost the same in either case, with just a single prop telling the component which path to use. Any framework, any runtime. The remote might be React, but it could be Vue, Svelte, or anything else. The host shouldn't care. And on the server, the same code should run on Node, Bun, Cloudflare Workers, or Vercel Edge with no rewrites. Host state still drives the remote after hydration. When the host re-renders with new props, the remote should re-render too. No re-fetch, no re-mount, no shared store between bundles. Honest failure modes. A timeout when the remote is slow, retry when a request fails, an explicit fallback for total failure, and a cache that respects auth boundaries. The things that decide whether SSR is a win or a regression when one team has a bad deploy. The last bullet is what most articles skip. SSR is easy in the happy path. The interesting code is what happens when one of the remotes is slow, down, or returning garbage. How It Works The idea is small: instead of importing remote components into the host server, the host pulls the rendered output in over HTTP at SSR time and streams it into its own response. The browser gets a full page on first paint. How that "pull" happens depends on how the remote is deployed. The package supports two modes for that: url mode — the remote has its own HTTP endpoint that returns rendered HTML. The host fetches that HTML during SSR. loader mode — the remote is a static React bundle on a CDN or S3, no server behind it. The host imports the component directly during SSR and renders it inline. Same host component ( <MFBridgeSSR> ) in both cases, just one prop changes. Both modes can live on the same page. The interesting part is what happens after hydration. The host has to push prop changes into the remote without re-fetching anything. I will get to that in a moment. I'll start with url mode since it is the more general case (any framework on the remote side, any runtime on the server), and then cover loader mode separately. url mode: remote with its own HTTP endpoint In url mode, the remote server does the SSR . The remote team runs their own runtime (Node, Bun, a Cloudflare Worker, a Next.js Route Handler, whatever they prefer) and exposes an HTTP endpoint that returns rendered HTML for the given props. The host's SSR pass just calls that endpoint and inlines the response into the page. Each microfrontend owns its own rendering pipeline. Remote handler import { createMFReactFragment } from '@mf-toolkit/mf-ssr/fragment' import { CheckoutWidget } from './CheckoutWidget' export const handler = createMFReactFragment(CheckoutWidget) handler is a plain Web fetch handler: (req: Request) => Promise<Response> . It reads props from the query string, renders the component to a stream with renderToReadableStream , and writes the props into a small <script> tag so the client can hydrate without going back to the network. One nuance worth flagging: those props go inside a <script> tag, so a raw </script> inside a string prop would close the tag prematurely and let user-controlled values escape into the HTML context. The handler escapes < , > , & , and U+2028/U+2029 to their \uXXXX equivalents before embedding. JSON.parse on the client treats them the same as the originals, but the browser's HTML parser never sees a closing tag. It is a few lines of code that close a real XSS hole. You wire the handler into whatever HTTP framework the remote team already uses. Hono, a Next.js Route Handler, Bun, plain Node, a Cloudflare Worker. The handler doesn't know about any of them. And because the whole thing is Web Streams, it runs on Cloudflare Workers, Vercel Edge, Bun, and Node 18+ without changes. Non-React remotes createMFReactFragment is a React-only helper. If the remote is Vue, Svelte, Solid, or vanilla JS, the team writes their own fetch handler instead, but it has to produce the same HTML shape the host expects: <div data-mf-ssr="checkout"> <script type="application/json" data-mf-props>{"orderId":"42"}</script> <div data-mf-app><!-- Vue / Svelte / whatever rendered HTML --></div> </div> The team uses their framework's SSR renderer ( renderToString for Vue, Svelte's SSR API, and so on) to produce the inner HTML, and serializes props into the <script data-mf-props> tag, applying the same < / > / & escaping. On the client, the remote mounts itself into [data-mf-app] and reads initial props from [data-mf-props] . If it needs prop updates from the host after hydration, it listens on the same DOMEventBus (exported from @mf-toolkit/mf-bridge ). The bus is a thin wrapper over native CustomEvent , with no React dependency, so it works fine for any framework. This path is more work than createMFReactFragment , but the contract is small and explicit. The host doesn't care which framework produced the inner HTML — as long as the wrapper structure matches, hydration finds the right slots. Host component <MFBridgeSSR url="https://checkout.acme.com/fragment" namespace="checkout" props={{ orderId, step }} fallback={<CheckoutSkeleton />} /> During SSR the host fetches the remote's HTML and streams it into the response. Each <MFBridgeSSR> lives in its own Suspense boundary, so a slow checkout doesn't block the header. They stream as they resolve. On the client the host hydrates, then waits for prop changes coming from React. Prop updates after hydration This was the part I cared about most. The remote is in its own React root, often in its own bundle, sometimes in a completely different framework. You can't re-render it like a normal child. So I used the one thing both sides already share at runtime: the DOM node the remote is mounted into. When the host re-renders with new props, the host fires a CustomEvent on that node. The remote listens for it and re-renders its root with the new props. No re-fetch, no global state, no coupling between bundles beyond a shared namespace string. // remote client entry import { hydrateWithBridge } from '@mf-toolkit/mf-bridge/hydrate' import { CheckoutWidget } from './CheckoutWidget' hydrateWithBridge(CheckoutWidget, { namespace: 'checkout' }) I picked this because it is isolated by construction. If a page has several MF slots, each one has its own mount node, so events never leak between them. And it is just DOM, so there is no bundler magic to debug when something goes wrong. Events and commands Prop streaming is one direction. For the other direction the same bus works in reverse. The host passes onEvent to receive events the remote emits, and a commandRef it can use to send imperative commands back: const resetRef = useRef<((type: string, payload?: unknown) => void) | null>(null) <MFBridgeSSR url="https://checkout.acme.com/fragment" namespace="checkout" props={{ orderId }} onEvent={(type, payload) => { if (type === 'orderPlaced') navigate('/thanks') }} commandRef={resetRef} /> // somewhere in host code, e.g. when the user switches accounts: resetRef.current?.('reset') On the remote, hydrateWithBridge accepts an onCommand handler, and DOMEventBus (exported from @mf-toolkit/mf-bridge ) lets the remote send events back: import { hydrateWithBridge } from '@mf-toolkit/mf-bridge/hydrate' import { DOMEventBus } from '@mf-toolkit/mf-bridge' hydrateWithBridge(CheckoutWidget, { namespace: 'checkout', onCommand: (type) => { if (type === 'reset') store.reset() }, }) // inside the widget, after a successful payment: const container = document.querySelector<HTMLElement>('[data-mf-namespace="checkout"]')! new DOMEventBus(container, 'checkout').send('event', { type: 'orderPlaced', payload: { orderId }, }) The channel is the same DOMEventBus , just with extra event names on top of propsChanged . So everything I said earlier about isolation still holds: events on one slot don't reach another, even when the remote is the same. loader mode: remote as a static bundle In loader mode, the host server does the SSR for the remote . The remote team ships only a static React bundle (CDN, S3, or a Module Federation host) and runs no server of their own. When the host renders its page server-side, it imports the remote component and renders it inline, the same way it renders any other component in the host tree. The remote has no SSR runtime and no rendering responsibility; the host does all the work. Host component const loadCheckout = () => import('checkout/Widget').then(m => m.CheckoutWidget) <MFBridgeSSR loader={loadCheckout} props={{ orderId, step }} fallback={<CheckoutSkeleton />} /> That is everything. No namespace , no errorFallback tricks needed for hydration, no client entry to write on the remote side. The package wraps the loader in React.lazy and renders the component inside the host's React tree, both server-side and after hydration. Props, events, commands Since the remote lives inside the host's React tree, every kind of communication is just React: Props — re-render normally. When the host's parent component re-renders with new props, the remote re-renders too. No DOMEventBus , no hydrateWithBridge , no propsChanged events. Events from remote to host — pass a callback through props. The remote calls it like any other handler. Commands from host to remote — pass them through props as well, or expose a ref through forwardRef . If you find yourself wanting onEvent / commandRef here, you are probably reaching for url mode. Requirements A few constraints come with this mode: Host must be able to resolve the loader on the server. The package calls your loader() function as-is. It doesn't fetch bundles from URLs itself. In practice this means Module Federation runtime on the host (or some other server-side dynamic import mechanism that knows how to find checkout/Widget ). Without that, the import fails in Node before any rendering happens. React only. The host literally calls the component during SSR, so the remote has to be a React component. For Vue / Svelte / vanilla remotes, use url mode. SSR-safe import. The remote's exposed module has to be importable on the server, which means no window , document , or other browser globals at the module top level. Move that code inside useEffect or behind a typeof window check. Stable loader reference. Define loadCheckout at module scope or wrap it in useCallback . The package caches the resulting React.lazy by loader reference so Suspense retries reuse the same promise. A new function on every render would break that and trigger an infinite retry loop. When to pick which | | url mode | loader mode | |----|----|----| | Remote infrastructure | Own HTTP endpoint (Node, Bun, Worker…) | Static bundle on CDN / S3 / MF host | | Remote framework | Any (React, Vue, Svelte, vanilla) | React only | | Isolation | Separate React root in the remote bundle | Inline in the host React tree | | Prop updates | DOM events via DOMEventBus | Native React re-render | | Events / commands | onEvent / commandRef | React props and refs | | Best for | Independent teams, mixed frameworks, polyrepo | Simple React remotes, no extra ops | Both modes use the same <MFBridgeSSR> and can be mixed freely on the same page. The Corner Cases I Spent Time On A few production scenarios I wanted to make sure the package handled honestly. Graceful degradation when the remote is down A remote can be slow, return a 5xx, or simply not respond. The host page shouldn't break because of one bad slot. mf-ssr accepts an errorFallback , and the trick is that the fallback can be the same remote mounted on the client through mf-bridge : import { MFBridgeSSR } from '@mf-toolkit/mf-ssr' import { MFBridgeLazy } from '@mf-toolkit/mf-bridge' <MFBridgeSSR url="https://checkout.acme.com/fragment" namespace="checkout" props={{ orderId }} timeout={2000} errorFallback={ <MFBridgeLazy register={() => import('checkout/entry').then(m => m.register)} props={{ orderId }} fallback={<CheckoutSkeleton />} /> } /> If the SSR fetch times out, the user still gets the widget. Just on the client, the same way it would have worked without mf-ssr at all. The page doesn't break. The slot loses its first-paint optimization, for that one request. When the remote recovers, the next render uses SSR again with no code change on either side. I like this case because it inverts the usual SSR-or-nothing tradeoff. SSR becomes the fast path, with a working client-side path sitting right behind it. Auth-isolated caching The host caches fragments by url + props + timeout . Fine for public content. Not fine when each user gets different HTML — they would share a cache slot and see each other's pages. So there is a cacheKey prop you set when the request carries auth: <MFBridgeSSR url="https://account.acme.com/fragment" namespace="account" props={{ view: 'orders' }} fetchOptions={{ headers: { authorization: `Bearer ${token}` } }} cacheKey={userId} /> The other side of the same coin is public fragments. The remote's fragment endpoint accepts a cacheControl option, so you can serve a product card as public, s-maxage=60, stale-while-revalidate=30 and let a CDN cache it for everyone: export const handler = createMFReactFragment(ProductCard, { cacheControl: 'public, s-maxage=60, stale-while-revalidate=30', vary: 'Accept-Language', }) One pattern handles per-user fragments, the other handles cacheable public ones. Same component on both sides. Multiple instances of the same remote Header, sidebar, and a content slot can all be the same remote on one page. The reason I sent prop updates through the mount DOM node, instead of a global event bus, is exactly this case: each <MFBridgeSSR> has its own DOM node, so events stay scoped to it. No filtering by instance id, no manual subscription bookkeeping. Warming the cache from RSC If you know a fragment is going to be needed, you can start the fetch before <MFBridgeSSR> even renders. Suspense then skips the fallback entirely: import { preloadFragment } from '@mf-toolkit/mf-ssr' // In a Server Component or route loader preloadFragment('https://checkout.acme.com/fragment', { orderId }) By the time the component renders down the tree, the HTML is already there. Where It Fits If your microfrontends share one build (a single bundler config that imports every remote), you don't need any of this. Use whatever your framework gives you. mf-ssr is for the case where each team builds and deploys independently. Different repos or not, the point is that there is no shared build step pulling everything into one Node process — and you still want a full page on first paint. The bet is that HTTP is a good enough boundary between teams, and that DOM events are a good enough way to keep host state in sync with remote rendering after hydration. The CSS isolation question, by the way, lives in mf-bridge , not here: it has shadowDom and adoptHostStyles props that wrap the remote in a Shadow DOM and forward host stylesheets (including Tailwind / CSS-in-JS chunks injected after mount) into the shadow root. SSR fragments don't use it by default since the HTML is inlined into the host response, but the option exists if you want it. Try It The package is published as @mf-toolkit/mf-ssr . The repo has runnable examples and also i’v made demo repo , where you can play with all my tools If you've solved the same problem in a different way, I'd be curious to compare notes. \ \n \n \n \ \
View original source — Hacker Noon ↗


