JORVEL

Cross-framework remotes

Module Federation federates JS modules, not React components — the transport is already framework-agnostic. What used to tie a JORVEL remote to React was the contract: a remote exposed a React component and the host rendered it into its React tree. JORVEL now also speaks a framework-neutral mount contract, so a host can embed a remote built with any framework.

The contract

A remote exposes a mount(ctx) / unmount(el) module (@jorvel/mount). The host hands it a DOM node + context; the remote bootstraps whatever framework it wants into that node and returns a disposer. The host never imports the remote's framework.

@jorvel/mount
ts
export interface JorvelMountModule {
  mount(ctx: JorvelMountContext): void | JorvelUnmount | Promise<void | JorvelUnmount>;
  unmount?(el: HTMLElement): void;
}

export interface JorvelMountContext {
  el: HTMLElement;                    // the node the remote owns
  subpath: string;                    // path relative to the mount prefix
  basePath: string;                   // the prefix the host mounted under
  params: Record<string, string>;     // route params matched by the host
  props?: Record<string, unknown>;    // host-passed props
  signal?: AbortSignal;               // aborted on unmount / navigation
}

React remotes

React remotes get the contract for free with @jorvel/adapter-react. Wrap your root and the remote is mountable by any host:

apps/dashboard/src/remote.tsx
tsx
import { defineReactRemote } from '@jorvel/adapter-react';
import { RemoteApp } from '@jorvel/runtime';
import { pages } from './jorvel.routes.js';

export default defineReactRemote(({ subpath }) => (
  <RemoteApp subpath={subpath} pages={pages} />
));

Back-compat preserved

Remotes that still export default a React component keep working unchanged —RemoteOutlet renders a component default directly and only takes the mount path when it detects a mount module. Nothing to migrate.

Other frameworks

Implement the contract directly — here a Vue remote:

apps/pricing/src/remote.ts
ts
import type { JorvelMountModule } from '@jorvel/mount';
import { createApp } from 'vue';
import Root from './Root.vue';

const remote: JorvelMountModule = {
  mount({ el, subpath }) {
    const app = createApp(Root, { subpath });
    app.mount(el);
    return () => app.unmount();
  },
};
export default remote;

Solid, Svelte (customElement), Angular (ApplicationRef), or plain DOM follow the same shape.

On the host

Nothing changes. RemoteOutlet auto-detects: a mount module is bridged into a DOM node it owns (with the neutral context), a React-component default is rendered inline. A plain-DOM host can mount without React using mountRemoteModule:

ts
import { asMountModule, mountRemoteModule } from '@jorvel/mount';

const mod = asMountModule(await importRemote());
if (mod) {
  const dispose = mountRemoteModule(mod, { el, subpath, basePath, params });
  // …on navigation away:
  dispose();
}

Embed anywhere: Web Component mode

For a host that isn't a JORVEL app — plain HTML, a CMS, or a page owned by another framework — wrap any mount module as a custom element. It reads routing context from attributes and drives the mount lifecycle from connect/disconnect:

ts
import { defineCustomElement } from '@jorvel/mount';
import remote from './remote.js'; // any JorvelMountModule

defineCustomElement('jorvel-pricing', remote);           // light DOM (host CSS applies)
// defineCustomElement('jorvel-pricing', remote, { shadow: true }); // isolated shadow DOM
html
<!-- Now usable in ANY page, no framework required -->
<jorvel-pricing subpath="/plans" basepath="/pricing" params='{"tier":"pro"}'></jorvel-pricing>

Extra attributes can be forwarded as props via { observedAttributes: ['data-theme'] } (kebab-case arrives camelCased). The element re-mounts when subpath changes and tears down on removal.

Cross-framework SSR

Server-render each framework's fragment, stitch them into one document, and hydrate each on the client — all through the neutral @jorvel/mount/ssr primitives.

1. Expose a server module. Alongside the client mount module, a remote exposes a renderToString (React does this via @jorvel/adapter-react/server):

remote.server.tsx (exposed as ./AppServer)
tsx
import { defineReactServerRemote } from '@jorvel/adapter-react/server';
import Root from './Root';
export default defineReactServerRemote(Root, { getState: (ctx) => ({ id: ctx.params.id }) });

2. Render + stitch on the server.Run each remote's renderer and compose the fragments into a document:

ts
import { renderFragment, composeFragments } from '@jorvel/mount/ssr';

const ctx = { subpath: '/plans', basePath: '/pricing', params: {} };
const fragments = await Promise.all([
  renderFragment('pricing', pricingServer, ctx),   // Vue
  renderFragment('reports', reportsServer, ctx),   // Angular
]);
const { html } = composeFragments(fragments, { template: shellHtml }); // {{head}} {{body}} {{state}}

Each fragment is wrapped in a marked mount point (data-jorvel-fragment="pricing") and its hydration state is serialized (XSS-safe) into a single script tag.

3. Hydrate on the client. One call finds every fragment, loads its remote, and re-mounts with hydrate: true — reusing the server DOM and seeding initialState:

ts
import { hydrateFragments } from '@jorvel/mount/ssr';

await hydrateFragments({
  pricing: () => import('pricing/App'),
  reports: () => import('reports/App'),
});

Every framework ships a server entry — @jorvel/adapter-<fw>/server with define<Fw>ServerRemote (React via react-dom/server, Vue via @vue/server-renderer, Solid via solid-js/web, Svelte via svelte/server, Angular via @angular/platform-server). On the client, adapters honor ctx.hydrate: React hydrateRoot, Vue createSSRApp, Solid hydrate, Svelte hydrate. Angular renders server-side and mounts on the client (app-level provideClientHydration for full DOM reuse).

Trade-offs

  • Shared runtime. The React singleton only dedupes React remotes. A Vue remote ships Vue, an Angular remote ships Angular — polyglot means a heavier baseline. Worth it only if you genuinely run mixed stacks.
  • Communication is neutral. No shared React context across the boundary — use @jorvel/event-bus / @jorvel/state (plain-JS pub/sub) or DOMCustomEvents.
  • Typed routes stay React-only. Foreign frameworks get the neutral contract; full TS route typing is a React-adapter perk.
  • SSR. Each framework has its own server renderer — cross-framework SSR is opt-in per adapter; CSR-first for non-React remotes.